diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 64f6c89d52..10ac26e4d5 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -76,6 +76,7 @@ eclair { option_quiesce = optional option_attribution_data = optional option_onion_messages = optional + zero_fee_commitments = disabled // This feature should only be enabled when acting as an LSP for mobile wallets. // When activating this feature, the peer-storage section should be customized to match desired SLAs. option_provide_storage = disabled diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index 12e5127208..e766a99ac2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -277,6 +277,12 @@ object Features { val mandatory = 38 } + // TODO: once the spec is final, set feature bit to 40 and activate in reference.conf + case object ZeroFeeCommitments extends Feature with InitFeature with NodeFeature with ChannelTypeFeature { + val rfcName = "zero_fee_commitments" + val mandatory = 140 + } + case object ProvideStorage extends Feature with InitFeature with NodeFeature { val rfcName = "option_provide_storage" val mandatory = 42 @@ -392,6 +398,7 @@ object Features { Quiescence, AttributionData, OnionMessages, + ZeroFeeCommitments, ProvideStorage, ChannelType, ScidAlias, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 2bee26cadb..da6124dd16 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -21,9 +21,9 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, Crypto, Satoshi, SatoshiLong} import fr.acinq.eclair.Setup.Seeds import fr.acinq.eclair.blockchain.fee._ -import fr.acinq.eclair.channel.ChannelFlags import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.{BalanceThreshold, ChannelConf, UnhandledExceptionStrategy} +import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes} import fr.acinq.eclair.crypto.Noise.KeyPair import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager, OnChainKeyManager} import fr.acinq.eclair.db._ @@ -361,7 +361,7 @@ object NodeParams extends Logging { require(htlcMinimum > 0.msat, "channel.htlc-minimum-msat must be strictly greater than 0") val maxAcceptedHtlcs = config.getInt("channel.max-accepted-htlcs") - require(maxAcceptedHtlcs <= Channel.MAX_ACCEPTED_HTLCS, s"channel.max-accepted-htlcs must be lower than ${Channel.MAX_ACCEPTED_HTLCS}") + require(maxAcceptedHtlcs <= Channel.maxAcceptedHtlcs(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()), s"channel.max-accepted-htlcs must be lower than ${Channel.maxAcceptedHtlcs(ChannelTypes.AnchorOutputsZeroFeeHtlcTx())}") val maxToLocalCLTV = CltvExpiryDelta(config.getInt("channel.max-to-local-delay-blocks")) val offeredCLTV = CltvExpiryDelta(config.getInt("channel.to-remote-delay-blocks")) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala index 2a009c4ebc..47a0458c6c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala @@ -93,6 +93,9 @@ case class OnChainFeeConf(feeTargets: FeeTargets, case Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => // If the fee has a large enough change, we update the fee. currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio + case Transactions.ZeroFeeCommitmentFormat => + // We never send update_fee when using zero-fee commitments. + false } } @@ -111,6 +114,7 @@ case class OnChainFeeConf(feeTargets: FeeTargets, val networkFeerate = feerates.fast val networkMinFee = feerates.minimum commitmentFormat match { + case Transactions.ZeroFeeCommitmentFormat => FeeratePerKw(0 sat) case Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat | Transactions.PhoenixSimpleTaprootChannelCommitmentFormat => // Since Bitcoin Core v28, 1-parent-1-child package relay has been deployed: it should be ok if the commit tx // doesn't propagate on its own. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala index e557ceed7b..3f5e83fb67 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala @@ -95,6 +95,15 @@ object ChannelTypes { override def commitmentFormat: CommitmentFormat = ZeroFeeHtlcTxAnchorOutputsCommitmentFormat override def toString: String = s"anchor_outputs_zero_fee_htlc_tx${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}" } + case class ZeroFeeCommitments(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType { + override def features: Set[ChannelTypeFeature] = Set( + if (scidAlias) Some(Features.ScidAlias) else None, + if (zeroConf) Some(Features.ZeroConf) else None, + Some(Features.ZeroFeeCommitments) + ).flatten + override def commitmentFormat: CommitmentFormat = ZeroFeeCommitmentFormat + override def toString: String = s"zero_fee_commitments${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}" + } case class SimpleTaprootChannelsStaging(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType { override def features: Set[ChannelTypeFeature] = Set( if (scidAlias) Some(Features.ScidAlias) else None, @@ -132,6 +141,10 @@ object ChannelTypes { SimpleTaprootChannelsStaging(zeroConf = true), SimpleTaprootChannelsStaging(scidAlias = true), SimpleTaprootChannelsStaging(scidAlias = true, zeroConf = true), + ZeroFeeCommitments(), + ZeroFeeCommitments(zeroConf = true), + ZeroFeeCommitments(scidAlias = true), + ZeroFeeCommitments(scidAlias = true, zeroConf = true), SimpleTaprootChannelsPhoenix, ).map { channelType => Features(channelType.features.map(_ -> FeatureSupport.Mandatory).toMap) -> channelType diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 42b9a65751..1961279655 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -724,8 +724,8 @@ object Commitment { commitmentInput: InputInfo, commitmentFormat: CommitmentFormat, spec: CommitmentSpec): (CommitTx, Seq[UnsignedHtlcTx]) = { - val outputs = makeCommitTxOutputs(localFundingKey.publicKey, remoteFundingPubKey, commitKeys.publicKeys, channelParams.localParams.paysCommitTxFees, commitParams.dustLimit, commitParams.toSelfDelay, spec, commitmentFormat) - val commitTx = makeCommitTx(commitmentInput, commitTxNumber, commitKeys.ourPaymentBasePoint, channelParams.remoteParams.paymentBasepoint, channelParams.localParams.isChannelOpener, outputs) + val outputs = makeCommitTxOutputs(localFundingKey.publicKey, remoteFundingPubKey, commitmentInput, commitKeys.publicKeys, channelParams.localParams.paysCommitTxFees, commitParams.dustLimit, commitParams.toSelfDelay, spec, commitmentFormat) + val commitTx = makeCommitTx(commitmentInput, commitTxNumber, commitKeys.ourPaymentBasePoint, channelParams.remoteParams.paymentBasepoint, channelParams.localParams.isChannelOpener, commitmentFormat, outputs) val htlcTxs = makeHtlcTxs(commitTx.tx, outputs, commitmentFormat) (commitTx, htlcTxs) } @@ -739,8 +739,8 @@ object Commitment { commitmentInput: InputInfo, commitmentFormat: CommitmentFormat, spec: CommitmentSpec): (CommitTx, Seq[UnsignedHtlcTx]) = { - val outputs = makeCommitTxOutputs(remoteFundingPubKey, localFundingKey.publicKey, commitKeys.publicKeys, !channelParams.localParams.paysCommitTxFees, commitParams.dustLimit, commitParams.toSelfDelay, spec, commitmentFormat) - val commitTx = makeCommitTx(commitmentInput, commitTxNumber, channelParams.remoteParams.paymentBasepoint, commitKeys.ourPaymentBasePoint, !channelParams.localParams.isChannelOpener, outputs) + val outputs = makeCommitTxOutputs(remoteFundingPubKey, localFundingKey.publicKey, commitmentInput, commitKeys.publicKeys, !channelParams.localParams.paysCommitTxFees, commitParams.dustLimit, commitParams.toSelfDelay, spec, commitmentFormat) + val commitTx = makeCommitTx(commitmentInput, commitTxNumber, channelParams.remoteParams.paymentBasepoint, commitKeys.ourPaymentBasePoint, !channelParams.localParams.isChannelOpener, commitmentFormat, outputs) val htlcTxs = makeHtlcTxs(commitTx.tx, outputs, commitmentFormat) (commitTx, htlcTxs) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 012d3e85f5..bf06636cd5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -114,12 +114,6 @@ object Helpers { // BOLT #2: The receiving node MUST fail the channel if: to_self_delay is unreasonably large. if (open.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(open.temporaryChannelId, open.toSelfDelay, nodeParams.channelConf.maxToLocalDelay)) - // BOLT #2: The receiving node MUST fail the channel if: max_accepted_htlcs is greater than 483. - if (open.maxAcceptedHtlcs > Channel.MAX_ACCEPTED_HTLCS) return Left(InvalidMaxAcceptedHtlcs(open.temporaryChannelId, open.maxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS)) - - // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing. - if (isFeeTooSmall(open.feeratePerKw)) return Left(FeerateTooSmall(open.temporaryChannelId, open.feeratePerKw)) - if (open.dustLimitSatoshis > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, open.dustLimitSatoshis, nodeParams.channelConf.maxRemoteDustLimit)) // BOLT #2: The receiving node MUST fail the channel if: dust_limit_satoshis is greater than channel_reserve_satoshis. @@ -140,9 +134,15 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => if (open.commitNonce_opt.isEmpty) return Left(MissingCommitNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes), commitmentNumber = 0)) - case _: AnchorOutputsCommitmentFormat => () + case _: SegwitV0CommitmentFormat => () } + // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing. + if (isFeeTooSmall(open.feeratePerKw, channelType)) return Left(FeerateTooSmall(open.temporaryChannelId, open.feeratePerKw)) + + // BOLT #2: The receiving node MUST fail the channel if max_accepted_htlcs is too high. + if (open.maxAcceptedHtlcs > Channel.maxAcceptedHtlcs(channelType)) return Left(InvalidMaxAcceptedHtlcs(open.temporaryChannelId, open.maxAcceptedHtlcs, Channel.maxAcceptedHtlcs(channelType))) + // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing or unreasonably large. val localFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, channelType.commitmentFormat) if (nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).isProposedCommitFeerateTooHigh(localFeeratePerKw, open.feeratePerKw)) return Left(FeerateTooDifferent(open.temporaryChannelId, localFeeratePerKw, open.feeratePerKw)) @@ -178,12 +178,6 @@ object Helpers { // BOLT #2: The receiving node MUST fail the channel if: to_self_delay is unreasonably large. if (open.toSelfDelay > nodeParams.channelConf.maxToLocalDelay) return Left(ToSelfDelayTooHigh(open.temporaryChannelId, open.toSelfDelay, nodeParams.channelConf.maxToLocalDelay)) - // BOLT #2: The receiving node MUST fail the channel if: max_accepted_htlcs is greater than 483. - if (open.maxAcceptedHtlcs > Channel.MAX_ACCEPTED_HTLCS) return Left(InvalidMaxAcceptedHtlcs(open.temporaryChannelId, open.maxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS)) - - // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing. - if (isFeeTooSmall(open.commitmentFeerate)) return Left(FeerateTooSmall(open.temporaryChannelId, open.commitmentFeerate)) - if (open.dustLimit < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(open.temporaryChannelId, open.dustLimit, Channel.MIN_DUST_LIMIT)) if (open.dustLimit > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, open.dustLimit, nodeParams.channelConf.maxRemoteDustLimit)) @@ -193,6 +187,12 @@ object Helpers { } val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) + // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing. + if (isFeeTooSmall(open.commitmentFeerate, channelType)) return Left(FeerateTooSmall(open.temporaryChannelId, open.commitmentFeerate)) + + // BOLT #2: The receiving node MUST fail the channel if max_accepted_htlcs is too high. + if (open.maxAcceptedHtlcs > Channel.maxAcceptedHtlcs(channelType)) return Left(InvalidMaxAcceptedHtlcs(open.temporaryChannelId, open.maxAcceptedHtlcs, Channel.maxAcceptedHtlcs(channelType))) + // BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing or unreasonably large. val localFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, channelType.commitmentFormat) if (nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).isProposedCommitFeerateTooHigh(localFeeratePerKw, open.commitmentFeerate)) return Left(FeerateTooDifferent(open.temporaryChannelId, localFeeratePerKw, open.commitmentFeerate)) @@ -218,7 +218,7 @@ object Helpers { case Right(channelType) => channelType } - if (accept.maxAcceptedHtlcs > Channel.MAX_ACCEPTED_HTLCS) return Left(InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, accept.maxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS)) + if (accept.maxAcceptedHtlcs > Channel.maxAcceptedHtlcs(channelType)) return Left(InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, accept.maxAcceptedHtlcs, Channel.maxAcceptedHtlcs(channelType))) if (accept.dustLimitSatoshis > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, accept.dustLimitSatoshis, nodeParams.channelConf.maxRemoteDustLimit)) if (accept.dustLimitSatoshis < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(accept.temporaryChannelId, accept.dustLimitSatoshis, Channel.MIN_DUST_LIMIT)) @@ -244,7 +244,7 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => if (accept.commitNonce_opt.isEmpty) return Left(MissingCommitNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes), commitmentNumber = 0)) - case _: AnchorOutputsCommitmentFormat => () + case _: SegwitV0CommitmentFormat => () } extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt).map(script_opt => (channelFeatures, script_opt)) } @@ -268,7 +268,7 @@ object Helpers { // BOLT #2: The receiving node MUST fail the channel if: push_msat is greater than funding_satoshis * 1000. if (accept.pushAmount > accept.fundingAmount) return Left(InvalidPushAmount(accept.temporaryChannelId, accept.pushAmount, accept.fundingAmount.toMilliSatoshi)) - if (accept.maxAcceptedHtlcs > Channel.MAX_ACCEPTED_HTLCS) return Left(InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, accept.maxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS)) + if (accept.maxAcceptedHtlcs > Channel.maxAcceptedHtlcs(channelType)) return Left(InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, accept.maxAcceptedHtlcs, Channel.maxAcceptedHtlcs(channelType))) if (accept.dustLimit < Channel.MIN_DUST_LIMIT) return Left(DustLimitTooSmall(accept.temporaryChannelId, accept.dustLimit, Channel.MIN_DUST_LIMIT)) if (accept.dustLimit > nodeParams.channelConf.maxRemoteDustLimit) return Left(DustLimitTooLarge(open.temporaryChannelId, accept.dustLimit, nodeParams.channelConf.maxRemoteDustLimit)) @@ -291,8 +291,12 @@ object Helpers { * @param remoteFeeratePerKw remote fee rate per kiloweight * @return true if the remote fee rate is too small */ - private def isFeeTooSmall(remoteFeeratePerKw: FeeratePerKw): Boolean = { - remoteFeeratePerKw < FeeratePerKw.MinimumFeeratePerKw + private def isFeeTooSmall(remoteFeeratePerKw: FeeratePerKw, channelType: SupportedChannelType): Boolean = { + channelType match { + case _: ChannelTypes.AnchorOutputs | _: ChannelTypes.AnchorOutputsZeroFeeHtlcTx => remoteFeeratePerKw < FeeratePerKw.MinimumFeeratePerKw + case _: ChannelTypes.SimpleTaprootChannelsStaging | ChannelTypes.SimpleTaprootChannelsPhoenix => remoteFeeratePerKw < FeeratePerKw.MinimumFeeratePerKw + case _: ChannelTypes.ZeroFeeCommitments => false + } } /** Compute the temporaryChannelId of a dual-funded channel. */ @@ -762,7 +766,7 @@ object Helpers { dummyClosingTxs.preferred_opt match { case Some(dummyTx) => commitment.commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => val dummyPubkey = commitment.remoteFundingPubKey val dummySig = IndividualSignature(Transactions.PlaceHolderSig) val dummySignedTx = dummyTx.aggregateSigs(dummyPubkey, dummyPubkey, dummySig, dummySig) @@ -800,7 +804,7 @@ object Helpers { closingTxs.remoteOnly_opt.flatMap(tx => localSig(tx, localNonces.remoteOnly)).map(ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(_)), ).flatten[ClosingCompleteTlv]) } - case _: AnchorOutputsCommitmentFormat => TlvStream(Set( + case _: SegwitV0CommitmentFormat => TlvStream(Set( closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndCloseeOutputs(tx.sign(localFundingKey, commitment.remoteFundingPubKey).sig)), closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserOutputOnly(tx.sign(localFundingKey, commitment.remoteFundingPubKey).sig)), closingTxs.remoteOnly_opt.map(tx => ClosingTlv.CloseeOutputOnly(tx.sign(localFundingKey, commitment.remoteFundingPubKey).sig)), @@ -854,7 +858,7 @@ object Helpers { case None => Left(MissingCloseSignature(commitment.channelId)) } } - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty && closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) case (Some(_), None) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) @@ -1037,7 +1041,7 @@ object Helpers { /** Create outputs of the local commitment transaction, allowing us for example to identify HTLC outputs. */ def makeLocalCommitTxOutputs(channelKeys: ChannelKeys, commitKeys: LocalCommitmentKeys, commitment: FullCommitment): Seq[CommitmentOutput] = { val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - makeCommitTxOutputs(fundingKey.publicKey, commitment.remoteFundingPubKey, commitKeys.publicKeys, commitment.localChannelParams.paysCommitTxFees, commitment.localCommitParams.dustLimit, commitment.localCommitParams.toSelfDelay, commitment.localCommit.spec, commitment.commitmentFormat) + makeCommitTxOutputs(fundingKey.publicKey, commitment.remoteFundingPubKey, commitment.commitInput(channelKeys), commitKeys.publicKeys, commitment.localChannelParams.paysCommitTxFees, commitment.localCommitParams.dustLimit, commitment.localCommitParams.toSelfDelay, commitment.localCommit.spec, commitment.commitmentFormat) } /** @@ -1170,10 +1174,10 @@ object Helpers { object RemoteClose { /** Transactions spending outputs of a remote commitment transaction. */ - case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteDelayedOutputTx], anchorTx_opt: Option[ClaimRemoteAnchorTx], htlcTxs: Seq[ClaimHtlcTx]) + case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteMainOutputTx], anchorTx_opt: Option[ClaimRemoteAnchorTx], htlcTxs: Seq[ClaimHtlcTx]) /** Claim all the outputs that belong to us in the remote commitment transaction (which can be either their current or next commitment). */ - def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, closingFeerate: FeeratePerKw, finalScriptPubKey: ByteVector, spendAnchorWithoutHtlcs: Boolean)(implicit log: LoggingAdapter): (RemoteCommitPublished, SecondStageTransactions) = { + def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, closingFeerate: FeeratePerKw, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector, spendAnchorWithoutHtlcs: Boolean)(implicit log: LoggingAdapter): (RemoteCommitPublished, SecondStageTransactions) = { require(remoteCommit.txId == commitTx.txid, "txid mismatch, provided tx is not the current remote commit tx") val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint) @@ -1182,8 +1186,16 @@ object Helpers { val (incomingHtlcs, htlcSuccessTxs) = claimIncomingHtlcOutputs(commitKeys, commitTx, outputs, commitment, remoteCommit, finalScriptPubKey) val (outgoingHtlcs, htlcTimeoutTxs) = claimOutgoingHtlcOutputs(commitKeys, commitTx, outputs, commitment, remoteCommit, finalScriptPubKey) val anchorOutput_opt = ClaimRemoteAnchorTx.findInput(commitTx, fundingKey, commitKeys, commitment.commitmentFormat).toOption + // When using v3 transactions, we can use our main output to pay commit fees if it's large enough. + // In that case, we don't need to create a dedicated anchor transaction which avoids using wallet inputs. + val useMainTxForAnchor = commitment.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => false + case ZeroFeeCommitmentFormat => + val commitFee = Transactions.weight2fee(feerates.fastest, commitTx.weight()) + mainTx_opt.exists(_.tx.txOut.map(_.amount).sum > commitFee) + } val spendAnchor = incomingHtlcs.nonEmpty || outgoingHtlcs.nonEmpty || spendAnchorWithoutHtlcs - val anchorTx_opt = if (spendAnchor) { + val anchorTx_opt = if (spendAnchor && !useMainTxForAnchor) { claimAnchor(fundingKey, commitKeys, commitTx, commitment.commitmentFormat) } else { None @@ -1207,10 +1219,10 @@ object Helpers { } /** Claim our main output from the remote commitment transaction, if available. */ - def claimMainOutput(commitKeys: RemoteCommitmentKeys, commitTx: Transaction, dustLimit: Satoshi, commitmentFormat: CommitmentFormat, feerate: FeeratePerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Option[ClaimRemoteDelayedOutputTx] = { + def claimMainOutput(commitKeys: RemoteCommitmentKeys, commitTx: Transaction, dustLimit: Satoshi, commitmentFormat: CommitmentFormat, feerate: FeeratePerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Option[ClaimRemoteMainOutputTx] = { commitmentFormat match { - case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => withTxGenerationLog("remote-main-delayed") { - ClaimRemoteDelayedOutputTx.createUnsignedTx(commitKeys, commitTx, dustLimit, finalScriptPubKey, feerate, commitmentFormat) + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat | ZeroFeeCommitmentFormat => withTxGenerationLog("remote-main") { + ClaimRemoteMainOutputTx.createUnsignedTx(commitKeys, commitTx, dustLimit, finalScriptPubKey, feerate, commitmentFormat) } } } @@ -1218,7 +1230,7 @@ object Helpers { /** Create outputs of the remote commitment transaction, allowing us for example to identify HTLC outputs. */ def makeRemoteCommitTxOutputs(channelKeys: ChannelKeys, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment, remoteCommit: RemoteCommit): Seq[CommitmentOutput] = { val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - makeCommitTxOutputs(commitment.remoteFundingPubKey, fundingKey.publicKey, commitKeys.publicKeys, !commitment.localChannelParams.paysCommitTxFees, commitment.remoteCommitParams.dustLimit, commitment.remoteCommitParams.toSelfDelay, remoteCommit.spec, commitment.commitmentFormat) + makeCommitTxOutputs(commitment.remoteFundingPubKey, fundingKey.publicKey, commitment.commitInput(channelKeys), commitKeys.publicKeys, !commitment.localChannelParams.paysCommitTxFees, commitment.remoteCommitParams.dustLimit, commitment.remoteCommitParams.toSelfDelay, remoteCommit.spec, commitment.commitmentFormat) } /** @@ -1329,7 +1341,7 @@ object Helpers { object RevokedClose { /** Transactions spending outputs of a revoked remote commitment transactions. */ - case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteDelayedOutputTx], mainPenaltyTx_opt: Option[MainPenaltyTx], htlcPenaltyTxs: Seq[HtlcPenaltyTx]) + case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteMainOutputTx], mainPenaltyTx_opt: Option[MainPenaltyTx], htlcPenaltyTxs: Seq[HtlcPenaltyTx]) /** Transactions spending outputs of confirmed remote HTLC transactions. */ case class ThirdStageTransactions(htlcDelayedPenaltyTxs: Seq[ClaimHtlcDelayedOutputPenaltyTx]) @@ -1377,8 +1389,8 @@ object Helpers { // First we will claim our main output right away. val mainTx_opt = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => withTxGenerationLog("remote-main-delayed") { - ClaimRemoteDelayedOutputTx.createUnsignedTx(commitKeys, commitTx, dustLimit, finalScriptPubKey, feerateMain, commitmentFormat) + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat | ZeroFeeCommitmentFormat => withTxGenerationLog("remote-main") { + ClaimRemoteMainOutputTx.createUnsignedTx(commitKeys, commitTx, dustLimit, finalScriptPubKey, feerateMain, commitmentFormat) } } 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 4c82bac323..20c8ab7e50 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 @@ -127,11 +127,11 @@ object Channel { } } - def commitParams(fundingAmount: Satoshi, unlimitedMaxHtlcValueInFlight: Boolean): ProposedCommitParams = ProposedCommitParams( + def commitParams(fundingAmount: Satoshi, channelType: SupportedChannelType, unlimitedMaxHtlcValueInFlight: Boolean): ProposedCommitParams = ProposedCommitParams( localDustLimit = dustLimit, localHtlcMinimum = htlcMinimum, localMaxHtlcValueInFlight = maxHtlcValueInFlight(fundingAmount, unlimitedMaxHtlcValueInFlight), - localMaxAcceptedHtlcs = maxAcceptedHtlcs, + localMaxAcceptedHtlcs = maxAcceptedHtlcs.min(Channel.maxAcceptedHtlcs(channelType)), toRemoteDelay = toRemoteDelay, ) } @@ -151,7 +151,14 @@ object Channel { // https://github.com/lightningnetwork/lightning-rfc/blob/master/02-peer-protocol.md#requirements val MAX_FUNDING_WITHOUT_WUMBO: Satoshi = 16777216 sat // = 2^24 - val MAX_ACCEPTED_HTLCS = 483 + + /** We limit the number of HTLCs that can be pending to ensure that the commit tx doesn't exceed standard weight limits. */ + def maxAcceptedHtlcs(channelType: SupportedChannelType): Int = channelType match { + case _: ChannelTypes.AnchorOutputs | _: ChannelTypes.AnchorOutputsZeroFeeHtlcTx => 483 + case _: ChannelTypes.SimpleTaprootChannelsStaging | ChannelTypes.SimpleTaprootChannelsPhoenix => 483 + // When using v3 transactions, the maximum package size is more restrictive than v2 transactions. + case _: ChannelTypes.ZeroFeeCommitments => 114 + } // We may need to rely on our peer's commit tx in certain cases (backup/restore) so we must ensure their transactions // can propagate through the bitcoin network (assuming bitcoin core nodes with default policies). @@ -398,7 +405,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val thirdStageTransactions = Closing.LocalClose.claimHtlcDelayedOutputs(c.localCommitPublished, channelKeys, commitment, closingFeerate, closing.finalScriptPubKey) doPublish(c.localCommitPublished, thirdStageTransactions) case Some(c: Closing.RemoteClose) => - val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, c.remoteCommit, c.remoteCommitPublished.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) + val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, c.remoteCommit, c.remoteCommitPublished.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) doPublish(c.remoteCommitPublished, secondStageTransactions, commitment) case Some(c: Closing.RecoveryClose) => // We cannot do anything in that case: we've already published our recovery transaction before restarting, @@ -427,12 +434,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall doPublish(lcp, secondStageTransactions, commitment) }) closing.remoteCommitPublished.foreach(rcp => { - val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, commitment.remoteCommit, rcp.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) + val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, commitment.remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) doPublish(rcp, secondStageTransactions, commitment) }) closing.nextRemoteCommitPublished.foreach(rcp => { val remoteCommit = commitment.nextRemoteCommit_opt.get - val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, rcp.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) + val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) doPublish(rcp, secondStageTransactions, commitment) }) closing.revokedCommitPublished.foreach(rvk => { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 1f10caa287..a414bbc4c5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -32,7 +32,7 @@ import fr.acinq.eclair.crypto.keymanager.{LocalCommitmentKeys, RemoteCommitmentK import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.io.Peer.OpenChannelResponse import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{SegwitV0CommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{AcceptChannel, AcceptChannelTlv, AnnouncementSignatures, ChannelReady, ChannelTlv, Error, FundingCreated, FundingSigned, OpenChannel, OpenChannelTlv, TlvStream} import fr.acinq.eclair.{MilliSatoshiLong, randomKey, toLongId} import scodec.bits.ByteVector @@ -79,7 +79,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val localShutdownScript = input.localChannelParams.upfrontShutdownScript_opt.getOrElse(ByteVector.empty) val localNonce = input.channelType.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0).publicNonce) - case _: AnchorOutputsCommitmentFormat => None + case _: SegwitV0CommitmentFormat => None } val open = OpenChannel( chainHash = nodeParams.chainHash, @@ -134,7 +134,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val localShutdownScript = d.initFundee.localChannelParams.upfrontShutdownScript_opt.getOrElse(ByteVector.empty) val localNonce = d.initFundee.channelType.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0).publicNonce) - case _: AnchorOutputsCommitmentFormat => None + case _: SegwitV0CommitmentFormat => None } val accept = AcceptChannel(temporaryChannelId = open.temporaryChannelId, dustLimitSatoshis = localCommitParams.dustLimit, @@ -233,7 +233,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { } case None => Left(MissingCommitNonce(d.channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) } - case _: AnchorOutputsCommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) + case _: SegwitV0CommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) } localSigOfRemoteTx match { case Left(f) => handleLocalError(f, d, None) @@ -303,7 +303,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { } case None => Left(MissingCommitNonce(channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) } - case _: AnchorOutputsCommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) + case _: SegwitV0CommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) } localSigOfRemoteTx match { case Left(f) => handleLocalError(f, d, Some(fc)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala index 0ce656c82e..6f7b934059 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.{BroadcastChannelUpdate, PeriodicRefresh, REFRESH_CHANNEL_UPDATE_INTERVAL} import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.db.RevokedHtlcInfoCleaner -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{SegwitV0CommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{RealShortChannelId, ShortChannelId} @@ -132,7 +132,7 @@ trait CommonFundingHandlers extends CommonHandlers { val localFundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val nextLocalNonce = NonceGenerator.verificationNonce(commitments.latest.fundingTxId, localFundingKey, commitments.latest.remoteFundingPubKey, 1) ChannelReady(params.channelId, nextPerCommitmentPoint, aliases.localAlias, nextLocalNonce.publicNonce) - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => ChannelReady(params.channelId, nextPerCommitmentPoint, aliases.localAlias) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index 451150db44..2cbee21922 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -24,7 +24,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{SegwitV0CommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{ClosingComplete, HtlcSettlementMessage, LightningMessage, Shutdown, UpdateMessage} import scodec.bits.ByteVector @@ -142,7 +142,7 @@ trait CommonHandlers { val localCloseeNonce = NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId) localCloseeNonce_opt = Some(localCloseeNonce) Shutdown(commitments.channelId, finalScriptPubKey, localCloseeNonce.publicNonce) - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => Shutdown(commitments.channelId, finalScriptPubKey) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index 6807f366ff..3608227d3a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -160,6 +160,12 @@ trait ErrorHandlers extends CommonHandlers { // The channel closing is retried on every reconnect of the channel, until it succeeds. log.warning("ignoring remote 'link failed to shutdown', probably coming from lnd") stay() sending Warning(d.channelId, "ignoring your 'link failed to shutdown' to avoid an unnecessary force-close") + } else if (hasCommitments.commitments.latest.commitmentFormat == ZeroFeeCommitmentFormat) { + // When using v3 transactions, we want to avoid using our wallet inputs as much as possible. + // It is much more convenient if our peer publishes their commitment transaction if they're having an issue, + // because we can use our main output for pay the fees of the commitment transaction directly (if necessary). + log.warning("ignoring remote error: waiting for remote commit tx to be published") + stay() sending Warning(d.channelId, "ignoring your error: please publish your commitment if you want to force-close the channel") } else { spendLocalCurrent(hasCommitments, maxClosingFeerateOverride_opt = None) } @@ -232,7 +238,10 @@ trait ErrorHandlers extends CommonHandlers { /** Publish 2nd-stage transactions for our local commitment. */ def doPublish(lcp: LocalCommitPublished, txs: Closing.LocalClose.SecondStageTransactions, commitment: FullCommitment): Unit = { - val publishCommitTx = PublishFinalTx(lcp.commitTx, commitment.fundingInput, "commit-tx", Closing.commitTxFee(commitment.commitInput(channelKeys), lcp.commitTx, commitment.localChannelParams.paysCommitTxFees), None) + val publishCommitTx_opt = commitment.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => Some(PublishFinalTx(lcp.commitTx, commitment.fundingInput, "commit-tx", Closing.commitTxFee(commitment.commitInput(channelKeys), lcp.commitTx, commitment.localChannelParams.paysCommitTxFees), None)) + case ZeroFeeCommitmentFormat => None // we will publish the commit-tx alongside the anchor tx + } val publishAnchorTx_opt = txs.anchorTx_opt match { case Some(anchorTx) if !lcp.isConfirmed => val confirmationTarget = Closing.confirmationTarget(commitment.localCommit, commitment.localCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf) @@ -241,7 +250,7 @@ trait ErrorHandlers extends CommonHandlers { } val publishMainDelayedTx_opt = txs.mainDelayedTx_opt.map(tx => PublishFinalTx(tx, None)) val publishHtlcTxs = txs.htlcTxs.map(htlcTx => PublishReplaceableTx(htlcTx, lcp.commitTx, commitment, Closing.confirmationTarget(htlcTx))) - val publishQueue = Seq(publishCommitTx) ++ publishAnchorTx_opt ++ publishMainDelayedTx_opt ++ publishHtlcTxs + val publishQueue = publishCommitTx_opt.toSeq ++ publishAnchorTx_opt ++ publishMainDelayedTx_opt ++ publishHtlcTxs publishIfNeeded(publishQueue, lcp.irrevocablySpent) if (!lcp.isConfirmed) { @@ -275,7 +284,7 @@ trait ErrorHandlers extends CommonHandlers { case _ => nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, maxClosingFeerateOverride_opt = None) } context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(commitments.commitInput(channelKeys), commitTx, d.commitments.localChannelParams.paysCommitTxFees), "remote-commit")) - val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitments, commitments.remoteCommit, commitTx, closingFeerate, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) + val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitments, commitments.remoteCommit, commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) val nextData = d match { case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished)) @@ -297,7 +306,7 @@ trait ErrorHandlers extends CommonHandlers { case _ => nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, maxClosingFeerateOverride_opt = None) } context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(commitment.commitInput(channelKeys), commitTx, d.commitments.localChannelParams.paysCommitTxFees), "next-remote-commit")) - val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, commitTx, closingFeerate, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) + val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs) val nextData = d match { case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished)) @@ -314,13 +323,19 @@ trait ErrorHandlers extends CommonHandlers { case Some(commit) if rcp.commitTx.txid == commit.txId => commit case _ => commitment.remoteCommit } + val confirmationTarget = Closing.confirmationTarget(remoteCommit, commitment.remoteCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf) val publishAnchorTx_opt = txs.anchorTx_opt match { - case Some(anchorTx) if !rcp.isConfirmed => - val confirmationTarget = Closing.confirmationTarget(remoteCommit, commitment.remoteCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf) - Some(PublishReplaceableTx(anchorTx, rcp.commitTx, commitment, confirmationTarget)) + case Some(anchorTx) if !rcp.isConfirmed => Some(PublishReplaceableTx(anchorTx, rcp.commitTx, commitment, confirmationTarget)) case _ => None } - val publishMainTx_opt = txs.mainTx_opt.map(tx => PublishFinalTx(tx, None)) + val publishMainTx_opt = txs.mainTx_opt.map(tx => commitment.commitmentFormat match { + case ZeroFeeCommitmentFormat => publishAnchorTx_opt match { + // Instead of creating a dedicated anchor transaction, we use our main output to pay commit fees whenever possible. + case None if !rcp.isConfirmed => PublishReplaceableTx(tx, rcp.commitTx, commitment, confirmationTarget) + case _ => PublishFinalTx(tx, None) + } + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => PublishFinalTx(tx, None) + }) val publishHtlcTxs = txs.htlcTxs.map(htlcTx => PublishReplaceableTx(htlcTx, rcp.commitTx, commitment, Closing.confirmationTarget(htlcTx))) val publishQueue = publishAnchorTx_opt ++ publishMainTx_opt ++ publishHtlcTxs publishIfNeeded(publishQueue, rcp.irrevocablySpent) @@ -334,7 +349,7 @@ trait ErrorHandlers extends CommonHandlers { // we will watch for its confirmation. This ensures that we detect double-spends that could come from: // - our own RBF attempts // - remote transactions for outputs that both parties may spend (e.g. HTLCs) - val watchSpentQueue = rcp.localOutput_opt ++ (if (!rcp.isConfirmed) rcp.anchorOutput_opt else None) ++ rcp.htlcOutputs.toSeq + val watchSpentQueue = rcp.localOutput_opt ++ (if (publishAnchorTx_opt.nonEmpty) rcp.anchorOutput_opt else None) ++ rcp.htlcOutputs.toSeq watchSpentIfNeeded(rcp.commitTx, watchSpentQueue, rcp.irrevocablySpent) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index 8d7acc3e8e..345943ed9e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -126,6 +126,7 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR private val dustLimit = commitment.localCommitParams.dustLimit private val commitFee: Satoshi = commitment.capacity - commitTx.txOut.map(_.amount).sum + private val commitFeerate: FeeratePerKw = Transactions.fee2rate(commitFee, commitTx.weight()) private val log = context.log @@ -133,7 +134,6 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR log.info("funding {} tx (targetFeerate={})", tx.desc, targetFeerate) tx match { case anchorTx: ClaimAnchorTx => - val commitFeerate = commitment.localCommit.spec.commitTxFeerate if (targetFeerate <= commitFeerate) { log.info("skipping {}: commit feerate is high enough (feerate={})", tx.desc, commitFeerate) // We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index 368cf56d0d..e137e91464 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.scalacompat.{OutPoint, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{Transaction, TxId} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext @@ -42,20 +42,20 @@ object ReplaceableTxPrePublisher { sealed trait Command case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], txInfo: ForceCloseTransaction, commitTx: Transaction, concurrentCommitTxs: Set[TxId]) extends Command - private case object ParentTxOk extends Command + private case class ParentTxOk(confirmed: Boolean) extends Command private case object FundingTxNotFound extends Command private case object CommitTxRecentlyConfirmed extends Command private case object CommitTxDeeplyConfirmed extends Command private case object ConcurrentCommitAvailable extends Command private case object ConcurrentCommitRecentlyConfirmed extends Command private case object ConcurrentCommitDeeplyConfirmed extends Command - private case object HtlcOutputAlreadySpent extends Command + private case object OutputAlreadySpent extends Command private case class UnknownFailure(reason: Throwable) extends Command // @formatter:on // @formatter:off sealed trait PreconditionsResult - case object PreconditionsOk extends PreconditionsResult + case class PreconditionsOk(commitTxConfirmed_opt: Option[Boolean]) extends PreconditionsResult case class PreconditionsFailed(reason: TxPublisher.TxRejectedReason) extends PreconditionsResult // @formatter:on @@ -68,9 +68,10 @@ object ReplaceableTxPrePublisher { txInfo match { case _: ClaimLocalAnchorTx => prePublisher.checkLocalCommitAnchorPreconditions(commitTx) case _: ClaimRemoteAnchorTx => prePublisher.checkRemoteCommitAnchorPreconditions(commitTx) - case _: SignedHtlcTx | _: ClaimHtlcTx => prePublisher.checkHtlcPreconditions(txInfo.desc, txInfo.input.outPoint, commitTx, concurrentCommitTxs) + case _: SignedHtlcTx | _: ClaimHtlcTx => prePublisher.check2ndStageTxPreconditions(txInfo, commitTx, concurrentCommitTxs) + case _: ClaimRemoteMainOutputTx => prePublisher.check2ndStageTxPreconditions(txInfo, commitTx, concurrentCommitTxs) case _ => - replyTo ! PreconditionsOk + replyTo ! PreconditionsOk(commitTxConfirmed_opt = None) Behaviors.stopped } } @@ -102,13 +103,13 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = true).flatMap { case true => // The funding output is unspent: let's publish our anchor transaction to get our local commit confirmed. - Future.successful(ParentTxOk) + Future.successful(ParentTxOk(confirmed = false)) case false => // The funding output is spent: we check whether our local commit is confirmed or in our mempool. bitcoinClient.getTxConfirmations(commitTx.txid).transformWith { case Success(Some(confirmations)) if confirmations >= nodeParams.channelConf.minDepth => Future.successful(CommitTxDeeplyConfirmed) case Success(Some(confirmations)) if confirmations > 0 => Future.successful(CommitTxRecentlyConfirmed) - case Success(Some(0)) => Future.successful(ParentTxOk) // our commit tx is unconfirmed, let's publish our anchor transaction + case Success(Some(0)) => Future.successful(ParentTxOk(confirmed = false)) // our commit tx is unconfirmed, let's publish our anchor transaction case _ => // Our commit tx is unconfirmed and cannot be found in our mempool: this means that a remote commit is // either confirmed or in our mempool. In that case, we don't want to use our local commit tx: the @@ -124,8 +125,8 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { - case ParentTxOk => - replyTo ! PreconditionsOk + case ParentTxOk(confirmed) => + replyTo ! PreconditionsOk(commitTxConfirmed_opt = Some(confirmed)) Behaviors.stopped case FundingTxNotFound => log.debug("funding tx could not be found, we don't know yet if we need to claim our anchor") @@ -146,7 +147,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case UnknownFailure(reason) => log.error("could not check local anchor preconditions, proceeding anyway: ", reason) // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. - replyTo ! PreconditionsOk + replyTo ! PreconditionsOk(commitTxConfirmed_opt = None) Behaviors.stopped } } @@ -166,7 +167,7 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case true => // The funding output is unspent, or spent by an *unconfirmed* transaction: let's publish our anchor // transaction, we may be able to replace our local commit with this (more interesting) remote commit. - Future.successful(ParentTxOk) + Future.successful(ParentTxOk(confirmed = false)) case false => // The funding output is spent by a confirmed commit tx: we check the status of our anchor's commit tx. bitcoinClient.getTxConfirmations(commitTx.txid).transformWith { @@ -184,8 +185,8 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { - case ParentTxOk => - replyTo ! PreconditionsOk + case ParentTxOk(confirmed) => + replyTo ! PreconditionsOk(commitTxConfirmed_opt = Some(confirmed)) Behaviors.stopped case FundingTxNotFound => log.debug("funding tx could not be found, we don't know yet if we need to claim our anchor") @@ -206,58 +207,67 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, case UnknownFailure(reason) => log.error("could not check remote anchor preconditions, proceeding anyway: ", reason) // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. - replyTo ! PreconditionsOk + replyTo ! PreconditionsOk(commitTxConfirmed_opt = None) Behaviors.stopped } } /** - * We first verify that the commit tx we're spending may confirm: if a conflicting commit tx is already confirmed, our - * HTLC transaction has become obsolete. Then we check that the HTLC output that we're spending isn't already spent + * We first verify that the commit tx we're spending may confirm: if a conflicting commit tx is already confirmed, + * our transaction has become obsolete. Then we check that the output that we're spending isn't already spent * by a confirmed transaction, which may happen in case of a race between HTLC-timeout and HTLC-success. */ - private def checkHtlcPreconditions(desc: String, input: OutPoint, commitTx: Transaction, concurrentCommitTxs: Set[TxId]): Behavior[Command] = { + private def check2ndStageTxPreconditions(txInfo: ForceCloseTransaction, commitTx: Transaction, concurrentCommitTxs: Set[TxId]): Behavior[Command] = { context.pipeToSelf(bitcoinClient.getTxConfirmations(commitTx.txid).flatMap { - case Some(_) => - // If the HTLC output is already spent by a confirmed transaction, there is no need for RBF: either this is one + case Some(confirmations) => + // If the output is already spent by a confirmed transaction, there is no need for RBF: either this is one // of our transactions (which thus has a high enough feerate), or it was a race with our peer and we lost. - bitcoinClient.isTransactionOutputSpent(input.txid, input.index.toInt).map { - case true => HtlcOutputAlreadySpent - case false => ParentTxOk + bitcoinClient.isTransactionOutputSpent(txInfo.input.outPoint.txid, txInfo.input.outPoint.index.toInt).map { + case true => OutputAlreadySpent + case false => ParentTxOk(confirmed = confirmations > 0) } case None => - // The parent commitment is unconfirmed: we shouldn't try to publish this HTLC transaction if a concurrent + // The parent commitment is unconfirmed: we shouldn't try to publish this transaction if a concurrent // commitment is deeply confirmed. checkConcurrentCommits(concurrentCommitTxs.toSeq).map { case Some(confirmations) if confirmations >= nodeParams.channelConf.minDepth => ConcurrentCommitDeeplyConfirmed case Some(_) => ConcurrentCommitRecentlyConfirmed - case None => ParentTxOk + case None => ParentTxOk(confirmed = false) } }) { case Success(result) => result case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { - case ParentTxOk => - replyTo ! PreconditionsOk - Behaviors.stopped + case ParentTxOk(confirmed) => + txInfo match { + case _: SignedHtlcTx | _: ClaimHtlcTx if commitTx.version == 3 && !confirmed => + // We don't yet use HTLC txs to spend the ephemeral anchor, so we need to wait for the commit tx to be confirmed. + // Otherwise, bitcoind will reject the transaction because it must spend the ephemeral anchor. + log.info("cannot publish v3 {} spending commitTxId={}: waiting for commit tx to confirm first", txInfo.desc, commitTx.txid) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case _ => + replyTo ! PreconditionsOk(commitTxConfirmed_opt = Some(confirmed)) + Behaviors.stopped + } case ConcurrentCommitRecentlyConfirmed => - log.debug("cannot publish {} spending commitTxId={}: concurrent commit tx was recently confirmed, let's check again later", desc, commitTx.txid) + log.debug("cannot publish {} spending commitTxId={}: concurrent commit tx was recently confirmed, let's check again later", txInfo.desc, commitTx.txid) // We keep retrying until the concurrent commit reaches min-depth to protect against reorgs. replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped case ConcurrentCommitDeeplyConfirmed => - log.warn("cannot publish {} spending commitTxId={}: concurrent commit is deeply confirmed", desc, commitTx.txid) + log.warn("cannot publish {} spending commitTxId={}: concurrent commit is deeply confirmed", txInfo.desc, commitTx.txid) replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) Behaviors.stopped - case HtlcOutputAlreadySpent => - log.warn("cannot publish {}: htlc output {} has already been spent", desc, input) + case OutputAlreadySpent => + log.warn("cannot publish {}: output {} has already been spent", txInfo.desc, txInfo.input.outPoint) replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) Behaviors.stopped case UnknownFailure(reason) => - log.error(s"could not check $desc preconditions, proceeding anyway: ", reason) - // If our checks fail, we don't want it to prevent us from trying to publish our htlc transactions. - replyTo ! PreconditionsOk + log.error(s"could not check ${txInfo.desc} preconditions, proceeding anyway: ", reason) + // If our checks fail, we don't want it to prevent us from trying to publish our transactions. + replyTo ! PreconditionsOk(commitTxConfirmed_opt = None) Behaviors.stopped } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index c5a294a147..9c9abddd5c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -18,12 +18,13 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.FundedTx import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext -import fr.acinq.eclair.transactions.Transactions.ClaimAnchorTx +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.{ClaimAnchorTx, ClaimRemoteMainOutputTx} import fr.acinq.eclair.{BlockHeight, NodeParams} import scala.concurrent.duration.{DurationInt, DurationLong} @@ -124,7 +125,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.receiveMessagePartial { case WrappedPreconditionsResult(result) => result match { - case ReplaceableTxPrePublisher.PreconditionsOk => checkTimeLocks() + case ReplaceableTxPrePublisher.PreconditionsOk(commitTxConfirmed_opt) => checkTimeLocks(commitTxConfirmed_opt) case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(txPublishContext.id, cmd, reason), None) } case UpdateConfirmationTarget(target) => @@ -134,10 +135,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkTimeLocks(): Behavior[Command] = { + def checkTimeLocks(commitTxConfirmed_opt: Option[Boolean]): Behavior[Command] = { cmd.txInfo match { // There are no time locks on anchor transactions, we can claim them right away. case _: ClaimAnchorTx => chooseFeerate() + case txInfo: ClaimRemoteMainOutputTx => fundWithoutWalletInputs(txInfo, commitTxConfirmed_opt) case _ => val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, bitcoinClient, txPublishContext), "time-locks-monitor") timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) @@ -159,7 +161,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.receiveMessagePartial { case CheckUtxosResult(isSafe, currentBlockHeight) => val targetFeerate = getFeerate(nodeParams.currentBitcoinCoreFeerates, confirmationTarget, currentBlockHeight, isSafe) - fund(targetFeerate) + fundWithWalletInputs(targetFeerate) case UpdateConfirmationTarget(target) => confirmationTarget = target Behaviors.same @@ -167,7 +169,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def fund(targetFeerate: FeeratePerKw): Behavior[Command] = { + private def fundWithWalletInputs(targetFeerate: FeeratePerKw): Behavior[Command] = { val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), Right(cmd.txInfo), cmd.commitTx, cmd.commitment, targetFeerate) Behaviors.receiveMessagePartial { @@ -199,6 +201,44 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } + private def fundWithoutWalletInputs(txInfo: ClaimRemoteMainOutputTx, commitTxConfirmed_opt: Option[Boolean]): Behavior[Command] = { + commitTxConfirmed_opt match { + case Some(true) => + // If the commitment transaction is already confirmed, we submit our main transaction alone. + // Its feerate was already set by the channel actor, we only need to sign and broadcast. + log.info("commit-tx is already confirmed, we don't need to spend the ephemeral anchor") + val signedTx = txInfo.sign() + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${signedTx.txid}") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), signedTx, parentTx_opt = None, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, txInfo.fee) + wait(FundedTx(txInfo, walletInputs_opt = None, signedTx, Transactions.fee2rate(txInfo.fee, signedTx.weight()))) + case _ => + // Otherwise, we want our transaction to pay fees for the commitment transaction as well. + // Note that we don't need any wallet utxo here, we'll simply lower our output amount. + val targetFeerate = getFeerate(nodeParams.currentBitcoinCoreFeerates, confirmationTarget, nodeParams.currentBlockHeight, hasEnoughSafeUtxos = true) + log.info("commit-tx is unconfirmed, we'll spend the ephemeral anchor (targetFeerate={})", targetFeerate) + val (signedTx, remainingFee) = setPackageFee(txInfo, targetFeerate) + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${signedTx.txid}") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), signedTx, parentTx_opt = Some(cmd.commitTx), cmd.input, nodeParams.channelConf.minDepth, cmd.desc, remainingFee) + wait(FundedTx(txInfo, walletInputs_opt = None, signedTx, targetFeerate)) + } + } + + private def setPackageFee(txInfo: ClaimRemoteMainOutputTx, targetFeerate: FeeratePerKw): (Transaction, Satoshi) = { + val commitFee = cmd.commitment.capacity - cmd.commitTx.txOut.map(_.amount).sum + if (Transactions.fee2rate(commitFee, cmd.commitTx.weight()) >= targetFeerate) { + // The commitment transaction already pays enough fees, so we don't take its weight into account. + val targetFee = Transactions.weight2fee(targetFeerate, txInfo.expectedWeight + cmd.commitment.commitmentFormat.anchorInputWeight) + val signedTx = txInfo.addSharedAnchorAndSign(targetFee, cmd.commitTx) + (signedTx, targetFee) + } else { + val packageWeight = cmd.commitTx.weight() + txInfo.expectedWeight + cmd.commitment.commitmentFormat.anchorInputWeight + val targetFee = Transactions.weight2fee(targetFeerate, packageWeight) + val missingFee = (targetFee - commitFee).max(0 sat) + val signedTx = txInfo.addSharedAnchorAndSign(missingFee, cmd.commitTx) + (signedTx, missingFee) + } + } + // Wait for our transaction to be confirmed or rejected from the mempool. // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. private def wait(tx: FundedTx): Behavior[Command] = { @@ -209,12 +249,19 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, val shouldRbf = cmd.txInfo match { // We only need to increase fees on the anchor tx if the commit tx isn't confirmed. case _: ClaimAnchorTx => !parentConfirmed + case _: ClaimRemoteMainOutputTx => !parentConfirmed case _ => true } if (shouldRbf) { - context.pipeToSelf(hasEnoughSafeUtxos(nodeParams.onChainFeeConf.safeUtxosThreshold)) { - case Success(isSafe) => CheckUtxosResult(isSafe, currentBlockHeight) - case Failure(_) => CheckUtxosResult(isSafe = false, currentBlockHeight) // if we can't check our utxos, we assume the worst + cmd.txInfo match { + case _: ClaimRemoteMainOutputTx => + // We don't use wallet utxos in that case. + context.self ! CheckUtxosResult(isSafe = true, nodeParams.currentBlockHeight) + case _ => + context.pipeToSelf(hasEnoughSafeUtxos(nodeParams.onChainFeeConf.safeUtxosThreshold)) { + case Success(isSafe) => CheckUtxosResult(isSafe, currentBlockHeight) + case Failure(_) => CheckUtxosResult(isSafe = false, currentBlockHeight) // if we can't check our utxos, we assume the worst + } } } Behaviors.same @@ -250,7 +297,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // We avoid a herd effect whenever we fee bump transactions. targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.channelConf.maxTxPublishRetryDelay.toMillis)).millis)) Behaviors.same - case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) + case BumpFee(targetFeerate) => + tx.txInfo match { + case txInfo: ClaimRemoteMainOutputTx => fundReplacementWithoutWalletInputs(targetFeerate, txInfo, tx) + case _ => fundReplacementWithWalletInputs(targetFeerate, tx) + } case UpdateConfirmationTarget(target) => confirmationTarget = target Behaviors.same @@ -259,14 +310,14 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } // Fund a replacement transaction because our previous attempt seems to be stuck in the mempool. - private def fundReplacement(targetFeerate: FeeratePerKw, previousTx: FundedTx): Behavior[Command] = { + private def fundReplacementWithWalletInputs(targetFeerate: FeeratePerKw, previousTx: FundedTx): Behavior[Command] = { log.info("bumping {} fees: previous feerate={}, next feerate={}", cmd.desc, previousTx.feerate, targetFeerate) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder-rbf") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), Left(previousTx), cmd.commitTx, cmd.commitment, targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { - case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, success.fundedTx) + case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, success.fundedTx, success.fundedTx.fee) case ReplaceableTxFunder.FundingFailed(_) => log.warn("could not fund {} replacement transaction (target feerate={})", cmd.desc, targetFeerate) wait(previousTx) @@ -287,17 +338,25 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } + // Increase the feerate of our transaction package because our previous attempt seems to be stuck in the mempool. + private def fundReplacementWithoutWalletInputs(targetFeerate: FeeratePerKw, txInfo: ClaimRemoteMainOutputTx, previousTx: FundedTx): Behavior[Command] = { + val (signedTx, remainingFee) = setPackageFee(txInfo, targetFeerate) + val bumpedTx = FundedTx(txInfo, walletInputs_opt = None, signedTx, targetFeerate) + publishReplacement(previousTx, bumpedTx, remainingFee) + } + // Publish an RBF attempt. We then have two concurrent transactions: the previous one and the updated one. // Only one of them can be in the mempool, so we wait for the other to be rejected. Once that's done, we're back to a // situation where we have one transaction in the mempool and wait for it to confirm. - private def publishReplacement(previousTx: FundedTx, bumpedTx: FundedTx): Behavior[Command] = { + private def publishReplacement(previousTx: FundedTx, bumpedTx: FundedTx, bumpedFee: Satoshi): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${bumpedTx.signedTx.txid}") val parentTx_opt = cmd.txInfo match { // Anchor output transactions are packaged with the corresponding commitment transaction. case _: ClaimAnchorTx => Some(cmd.commitTx) + case _: ClaimRemoteMainOutputTx => Some(cmd.commitTx) case _ => None } - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, parentTx_opt, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, bumpedTx.fee) + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, parentTx_opt, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, bumpedFee) Behaviors.receiveMessagePartial { case WrappedTxResult(txResult) => txResult match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala index c782a1fc63..512f8bef7b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala @@ -229,7 +229,7 @@ class Peer(val nodeParams: NodeParams, requireConfirmedInputs = requireConfirmedInputs, requestFunding_opt = c.requestFunding_opt, localChannelParams = localParams, - proposedCommitParams = nodeParams.channelConf.commitParams(c.fundingAmount, unlimitedMaxHtlcValueInFlight = false), + proposedCommitParams = nodeParams.channelConf.commitParams(c.fundingAmount, channelType, unlimitedMaxHtlcValueInFlight = false), remote = d.peerConnection, remoteInit = d.remoteInit, channelFlags = c.channelFlags_opt.getOrElse(nodeParams.channelConf.channelFlags), @@ -293,7 +293,7 @@ class Peer(val nodeParams: NodeParams, pushAmount_opt = None, requireConfirmedInputs = false, localChannelParams = localParams, - proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingSatoshis, unlimitedMaxHtlcValueInFlight = false), + proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingSatoshis, channelType, unlimitedMaxHtlcValueInFlight = false), remote = d.peerConnection, remoteInit = d.remoteInit, channelConfig = channelConfig, @@ -308,7 +308,7 @@ class Peer(val nodeParams: NodeParams, pushAmount_opt = None, requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, localChannelParams = localParams, - proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingAmount + addFunding_opt.map(_.fundingAmount).getOrElse(0 sat), unlimitedMaxHtlcValueInFlight = false), + proposedCommitParams = nodeParams.channelConf.commitParams(open.fundingAmount + addFunding_opt.map(_.fundingAmount).getOrElse(0 sat), channelType, unlimitedMaxHtlcValueInFlight = false), remote = d.peerConnection, remoteInit = d.remoteInit, channelConfig = channelConfig, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index 8793de795e..7a3e6ec18d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -36,6 +36,7 @@ object CommitmentOutput { case class ToRemote(txOut: TxOut) extends CommitmentOutput case class ToLocalAnchor(txOut: TxOut) extends CommitmentOutput case class ToRemoteAnchor(txOut: TxOut) extends CommitmentOutput + case class ToSharedAnchor(txOut: TxOut) extends CommitmentOutput // If there is an output for an HTLC in the commit tx, there is also a 2nd-level HTLC tx. case class InHtlc(htlc: IncomingHtlc, txOut: TxOut, htlcDelayedOutput: TxOut) extends CommitmentOutput case class OutHtlc(htlc: OutgoingHtlc, txOut: TxOut, htlcDelayedOutput: TxOut) extends CommitmentOutput @@ -94,6 +95,7 @@ final case class CommitmentSpec(htlcs: Set[DirectedHtlc], commitTxFeerate: Feera def htlcTxFeerate(commitmentFormat: CommitmentFormat): FeeratePerKw = commitmentFormat match { case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => FeeratePerKw(0 sat) + case ZeroFeeCommitmentFormat => FeeratePerKw(0 sat) case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => commitTxFeerate } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala index b64c842ec7..192b4c0e8a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala @@ -44,6 +44,7 @@ object Scripts { private def htlcRemoteSighash(commitmentFormat: CommitmentFormat): Int = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => SIGHASH_SINGLE | SIGHASH_ANYONECANPAY + case ZeroFeeCommitmentFormat => SIGHASH_SINGLE | SIGHASH_ANYONECANPAY } /** Sort public keys using lexicographic ordering. */ @@ -204,6 +205,7 @@ object Scripts { def htlcOffered(keys: CommitmentPublicKeys, paymentHash: ByteVector32, commitmentFormat: CommitmentFormat): Seq[ScriptElt] = { val addCsvDelay = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => true + case ZeroFeeCommitmentFormat => false } // @formatter:off // To you with revocation key @@ -262,6 +264,7 @@ object Scripts { def htlcReceived(keys: CommitmentPublicKeys, paymentHash: ByteVector32, lockTime: CltvExpiry, commitmentFormat: CommitmentFormat): Seq[ScriptElt] = { val addCsvDelay = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => true + case ZeroFeeCommitmentFormat => false } // @formatter:off // To you with revocation key 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 0a9e5f37b2..5a4bcbc840 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 @@ -16,13 +16,13 @@ package fr.acinq.eclair.transactions +import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.SigHash._ import fr.acinq.bitcoin.SigVersion._ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} import fr.acinq.bitcoin.scalacompat.KotlinUtils._ -import fr.acinq.bitcoin.scalacompat._ -import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Musig2.{IndividualNonce, LocalNonce} +import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature @@ -81,7 +81,7 @@ object Transactions { def claimHtlcTimeoutWeight: Int /** Weight of a fully signed [[ClaimLocalDelayedOutputTx]] transaction. */ def toLocalDelayedWeight: Int - /** Weight of a fully signed [[ClaimRemoteDelayedOutputTx]] transaction. */ + /** Weight of a fully signed [[ClaimRemoteMainOutputTx]] transaction. */ def toRemoteWeight: Int /** Weight of a fully signed [[HtlcDelayedTx]] 3rd-stage transaction (spending the output of an [[HtlcTx]]). */ def htlcDelayedWeight: Int @@ -146,6 +146,33 @@ object Transactions { override def toString: String = "anchor_outputs" } + /** + * Commitment format that adds a shared anchor output (standard P2A script) to the commitment transaction and uses + * v3 (TRUC) transactions to allow CPFP and package relay. + */ + case object ZeroFeeCommitmentFormat extends SegwitV0CommitmentFormat { + override val commitWeight: Int = 773 + override val anchorInputWeight: Int = 164 + override val htlcOutputWeight: Int = 172 + override val htlcTimeoutInputWeight: Int = 449 + override val htlcTimeoutWeight: Int = 663 + override val htlcSuccessInputWeight: Int = 488 + override val htlcSuccessWeight: Int = 703 + override val claimHtlcSuccessWeight: Int = 571 + override val claimHtlcTimeoutWeight: Int = 544 + override val toLocalDelayedWeight: Int = 483 + override val toRemoteWeight: Int = 438 + override val htlcDelayedWeight: Int = 483 + override val mainPenaltyWeight: Int = 483 + override val htlcOfferedPenaltyWeight: Int = 572 + override val htlcReceivedPenaltyWeight: Int = 577 + override val claimHtlcPenaltyWeight: Int = 483 + // The anchor output amount is capped at this value: afterwards, trimmed outputs go directly to fees. + val maxAnchorAmount: Satoshi = 240 sat + + override def toString: String = "zero_fee_commitments" + } + sealed trait TaprootCommitmentFormat extends CommitmentFormat sealed trait SimpleTaprootChannelCommitmentFormat extends TaprootCommitmentFormat { @@ -190,6 +217,12 @@ object Transactions { } object RedeemInfo { sealed trait SegwitV0 extends RedeemInfo { def redeemScript: ByteVector } + /** @param publicKey the public key for this p2wpkh input. */ + case class P2wpkh(publicKey: PublicKey) extends SegwitV0 { + // This looks surprising at first glance, but p2pkh is the correct redeem script for p2wpkh outputs. + override val redeemScript: ByteVector = Script.write(Script.pay2pkh(publicKey)) + override val pubkeyScript: ByteVector = Script.write(Script.pay2wpkh(publicKey)) + } /** @param redeemScript the actual script must be known to redeem pay2wsh inputs. */ case class P2wsh(redeemScript: ByteVector) extends SegwitV0 { override val pubkeyScript: ByteVector = Script.write(Script.pay2wsh(redeemScript)) @@ -216,6 +249,10 @@ object Transactions { val redeemScript: ByteVector = leaf.getScript override val pubkeyScript: ByteVector = Script.write(Script.pay2tr(internalKey, Some(scriptTree))) } + /** Standard pay-to-anchor (P2A) output (introduced in https://github.com/bitcoin/bitcoin/pull/30352). */ + case object PayToAnchor extends Taproot { + override val pubkeyScript: ByteVector = Script.write(Script.pay2anchor) + } } // @formatter:on @@ -251,6 +288,9 @@ object Transactions { Transaction.signInputTaprootKeyPath(key, tx, inputIndex, spentOutputs, sighash, t.scriptTree_opt) case s: RedeemInfo.TaprootScriptPath => Transaction.signInputTaprootScriptPath(key, tx, inputIndex, spentOutputs, sighash, s.leafHash) + case RedeemInfo.PayToAnchor => + // We never call this function for PayToAnchor inputs, which don't require a signature. + ByteVector64.Zeroes } } @@ -266,6 +306,7 @@ object Transactions { case s: RedeemInfo.TaprootScriptPath => val data = Transaction.hashForSigningTaprootScriptPath(tx, inputIndex, Seq(input.txOut), sighash, s.leafHash) Crypto.verifySignatureSchnorr(data, sig, publicKey.xOnly) + case RedeemInfo.PayToAnchor => true } } else { false @@ -464,13 +505,13 @@ object Transactions { * Transactions spending a remote [[CommitTx]] or one of its descendants. * * When a current remote [[CommitTx]] is published: - * - When using anchor outputs, [[ClaimRemoteDelayedOutputTx]] spends the to-local output of [[CommitTx]] + * - When using anchor outputs, [[ClaimRemoteMainOutputTx]] spends the to-local output of [[CommitTx]] * - When using anchor outputs, [[ClaimRemoteAnchorTx]] spends the to-local anchor of [[CommitTx]] * - [[ClaimHtlcSuccessTx]] spends received htlc outputs of [[CommitTx]] for which we have the preimage * - [[ClaimHtlcTimeoutTx]] spends sent htlc outputs of [[CommitTx]] after a timeout * * When a revoked remote [[CommitTx]] is published: - * - When using anchor outputs, [[ClaimRemoteDelayedOutputTx]] spends the to-local output of [[CommitTx]] + * - When using anchor outputs, [[ClaimRemoteMainOutputTx]] spends the to-local output of [[CommitTx]] * - [[MainPenaltyTx]] spends the remote main output using the revocation secret * - [[HtlcPenaltyTx]] spends all htlc outputs using the revocation secret (and competes with [[HtlcSuccessTx]] and [[HtlcTimeoutTx]] published by the remote node) * - [[ClaimHtlcDelayedOutputPenaltyTx]] spends [[HtlcSuccessTx]] transactions published by the remote node using the revocation secret @@ -509,7 +550,7 @@ object Transactions { // @formatter:on def sighash(txOwner: TxOwner): Int = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => txOwner match { + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => txOwner match { case TxOwner.Local => SIGHASH_ALL case TxOwner.Remote => SIGHASH_SINGLE | SIGHASH_ANYONECANPAY } @@ -593,7 +634,7 @@ object Transactions { val htlc = output.htlc.add val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex)) val tx = Transaction( - version = 2, + version = commitTx.version, txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = output.htlcDelayedOutput :: Nil, lockTime = 0 @@ -602,7 +643,7 @@ object Transactions { } def redeemInfo(commitKeys: CommitmentPublicKeys, paymentHash: ByteVector32, htlcExpiry: CltvExpiry, commitmentFormat: CommitmentFormat): RedeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(htlcReceived(commitKeys, paymentHash, htlcExpiry, commitmentFormat)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -647,7 +688,7 @@ object Transactions { val htlc = output.htlc.add val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex)) val tx = Transaction( - version = 2, + version = commitTx.version, txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = output.htlcDelayedOutput :: Nil, lockTime = htlc.cltvExpiry.toLong @@ -656,7 +697,7 @@ object Transactions { } def redeemInfo(commitKeys: CommitmentPublicKeys, paymentHash: ByteVector32, commitmentFormat: CommitmentFormat): RedeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(htlcOffered(commitKeys, paymentHash, commitmentFormat)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -672,7 +713,7 @@ object Transactions { override def sign(): Transaction = { val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toLocalDelay)) val sig = sign(commitKeys.ourDelayedPaymentKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) witnessToLocalDelayedAfterDelay(sig, redeemScript) @@ -695,7 +736,7 @@ object Transactions { val input = InputInfo(OutPoint(htlcTx, outputIndex), htlcTx.txOut(outputIndex)) val amount = input.txOut.amount - weight2fee(feerate, commitmentFormat.htlcDelayedWeight) val tx = Transaction( - version = 2, + version = htlcTx.version, txIn = TxIn(input.outPoint, ByteVector.empty, toLocalDelay.toInt) :: Nil, txOut = TxOut(amount, localFinalScriptPubKey) :: Nil, lockTime = 0 @@ -706,7 +747,7 @@ object Transactions { } def redeemInfo(commitKeys: CommitmentPublicKeys, toLocalDelay: CltvExpiryDelta, commitmentFormat: CommitmentFormat): RedeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys, toLocalDelay)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -732,7 +773,7 @@ object Transactions { override def sign(): Transaction = { // Note that in/out HTLCs are inverted in the remote commitment: from their point of view it's an offered (outgoing) HTLC. val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(htlcOffered(commitKeys.publicKeys, paymentHash, commitmentFormat)) val sig = sign(commitKeys.ourHtlcKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) witnessClaimHtlcSuccessFromCommitTx(sig, preimage, redeemScript) @@ -792,7 +833,7 @@ object Transactions { override def sign(): Transaction = { // Note that in/out HTLCs are inverted in the remote commitment: from their point of view it's a received (incoming) HTLC. val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(htlcReceived(commitKeys.publicKeys, paymentHash, htlcExpiry, commitmentFormat)) val sig = sign(commitKeys.ourHtlcKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) witnessClaimHtlcTimeoutFromCommitTx(sig, redeemScript) @@ -854,12 +895,16 @@ object Transactions { commitmentFormat match { case _: AnchorOutputsCommitmentFormat => RedeemInfo.P2wsh(anchor(fundingKey)) case _: SimpleTaprootChannelCommitmentFormat => RedeemInfo.TaprootKeyPath(paymentKey.xOnly, Some(Taproot.anchorScriptTree)) + case ZeroFeeCommitmentFormat => RedeemInfo.PayToAnchor } } - def createUnsignedTx(input: InputInfo): Transaction = { + def createUnsignedTx(input: InputInfo, commitmentFormat: CommitmentFormat): Transaction = { Transaction( - version = 2, + version = commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => 2 + case ZeroFeeCommitmentFormat => 3 + }, txIn = TxIn(input.outPoint, ByteVector.empty, 0) :: Nil, txOut = Nil, // anchor is only used to bump fees, the output will be added later depending on available inputs lockTime = 0 @@ -884,6 +929,7 @@ object Transactions { val redeemInfo = RedeemInfo.TaprootKeyPath(anchorKey.xOnlyPublicKey(), Some(Taproot.anchorScriptTree)) val sig = toSign.sign(anchorKey, sighash, redeemInfo, walletInputs.spentUtxos) Script.witnessKeyPathPay2tr(sig) + case ZeroFeeCommitmentFormat => Script.witnessPay2anchor } toSign.tx.updateWitness(toSign.inputIndex, witness) } @@ -900,7 +946,7 @@ object Transactions { } def createUnsignedTx(fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, commitTx: Transaction, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimLocalAnchorTx] = { - findInput(commitTx, fundingKey, commitKeys, commitmentFormat).map(input => ClaimLocalAnchorTx(fundingKey, commitKeys, input, ClaimAnchorTx.createUnsignedTx(input), commitmentFormat)) + findInput(commitTx, fundingKey, commitKeys, commitmentFormat).map(input => ClaimLocalAnchorTx(fundingKey, commitKeys, input, ClaimAnchorTx.createUnsignedTx(input, commitmentFormat), commitmentFormat)) } } @@ -920,6 +966,7 @@ object Transactions { val redeemInfo = RedeemInfo.TaprootKeyPath(commitKeys.ourPaymentKey.xOnlyPublicKey(), Some(Taproot.anchorScriptTree)) val sig = toSign.sign(commitKeys.ourPaymentKey, sighash, redeemInfo, walletInputs.spentUtxos) Script.witnessKeyPathPay2tr(sig) + case ZeroFeeCommitmentFormat => Script.witnessPay2anchor } toSign.tx.updateWitness(toSign.inputIndex, witness) } @@ -936,13 +983,28 @@ object Transactions { } def createUnsignedTx(fundingKey: PrivateKey, commitKeys: RemoteCommitmentKeys, commitTx: Transaction, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimRemoteAnchorTx] = { - findInput(commitTx, fundingKey, commitKeys, commitmentFormat).map(input => ClaimRemoteAnchorTx(fundingKey, commitKeys, input, ClaimAnchorTx.createUnsignedTx(input), commitmentFormat)) + findInput(commitTx, fundingKey, commitKeys, commitmentFormat).map(input => ClaimRemoteAnchorTx(fundingKey, commitKeys, input, ClaimAnchorTx.createUnsignedTx(input, commitmentFormat), commitmentFormat)) } } - /** This transaction spends our main balance from the remote commitment with a 1-block relative delay. */ - case class ClaimRemoteDelayedOutputTx(commitKeys: RemoteCommitmentKeys, input: InputInfo, tx: Transaction, commitmentFormat: CommitmentFormat) extends RemoteCommitForceCloseTransaction { - override val desc: String = "remote-main-delayed" + /** + * This transaction spends our main balance from the remote commitment. + * + * When using [[AnchorOutputsCommitmentFormat]] or [[SimpleTaprootChannelCommitmentFormat]], there is a CSV-1 on the + * output to allow CPFP carve-out on the anchor outputs. + * + * Otherwise, it directly sends to a public key (p2wpkh or p2tr). In theory we could avoid making a 2nd-stage transaction + * (and directly use a public key from our bitcoin wallet), but it adds complexity and doesn't work in the case where + * we upgrade an anchor outputs channel to v3 during a splice (since the public key was generated when the channel was + * opened and cannot be changed afterwards). Another reason to use a 2nd-stage transaction is because that's how we + * pay the on-chain fees for the commitment transaction (which doesn't pay any fees) by also spending the P2A output + * (which avoids the need for external wallet inputs). + * The only case where it is wasteful to have this 2nd-stage transaction is if the remote peer paid the fees for the + * remote commitment and it confirmed: in that case, we haven't paid any on-chain fees yet for the force-close, and + * our peer has paid for the largest transaction, so it's fine even though it's not optimal. + */ + case class ClaimRemoteMainOutputTx(commitKeys: RemoteCommitmentKeys, input: InputInfo, tx: Transaction, commitmentFormat: CommitmentFormat) extends RemoteCommitForceCloseTransaction { + override val desc: String = "remote-main" override val expectedWeight: Int = commitmentFormat.toRemoteWeight override def sign(): Transaction = { @@ -956,13 +1018,35 @@ object Transactions { val redeemInfo = RedeemInfo.TaprootScriptPath(NUMS_POINT.xOnly, scriptTree, scriptTree.hash()) val sig = sign(commitKeys.ourPaymentKey, sighash, redeemInfo, extraUtxos = Map.empty) Script.witnessScriptPathPay2tr(redeemInfo.internalKey, scriptTree, ScriptWitness(Seq(sig)), scriptTree) + case ZeroFeeCommitmentFormat => + val redeemInfo = RedeemInfo.P2wpkh(commitKeys.ourPaymentKey.publicKey) + val sig = sign(commitKeys.ourPaymentKey, sighash, redeemInfo, extraUtxos = Map.empty) + Script.witnessPay2wpkh(redeemInfo.publicKey, der(sig)) } tx.updateWitness(inputIndex, witness) } + + /** Add the shared P2A anchor to this transaction to pays fees for the parent commit tx. */ + def addSharedAnchorAndSign(fee: Satoshi, commitTx: Transaction): Transaction = { + findPubKeyScriptIndex(commitTx, RedeemInfo.PayToAnchor.pubkeyScript) match { + case Left(_) => sign() + case Right(idx) => + val dustLimit = Scripts.dustLimit(tx.txOut.head.publicKeyScript) + val anchorInput = InputInfo(OutPoint(commitTx, idx), commitTx.txOut(idx)) + val amountOut = (amountIn + anchorInput.txOut.amount - fee).max(dustLimit) + val tx1 = tx.copy( + txIn = tx.txIn :+ TxIn(anchorInput.outPoint, ByteVector.empty, 0, Script.witnessPay2anchor), + txOut = Seq(tx.txOut.head.copy(amount = amountOut)) + ) + val redeemInfo = RedeemInfo.P2wpkh(commitKeys.ourPaymentKey.publicKey) + val sig = this.copy(tx = tx1).sign(commitKeys.ourPaymentKey, sighash, redeemInfo, Map(anchorInput.outPoint -> anchorInput.txOut)) + tx1.updateWitness(inputIndex, Script.witnessPay2wpkh(redeemInfo.publicKey, der(sig))) + } + } } - object ClaimRemoteDelayedOutputTx { - def createUnsignedTx(commitKeys: RemoteCommitmentKeys, commitTx: Transaction, localDustLimit: Satoshi, localFinalScriptPubKey: ByteVector, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimRemoteDelayedOutputTx] = { + object ClaimRemoteMainOutputTx { + def createUnsignedTx(commitKeys: RemoteCommitmentKeys, commitTx: Transaction, localDustLimit: Satoshi, localFinalScriptPubKey: ByteVector, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimRemoteMainOutputTx] = { val redeemInfo = commitmentFormat match { case _: AnchorOutputsCommitmentFormat => val redeemScript = Script.write(toRemoteDelayed(commitKeys.publicKeys)) @@ -970,19 +1054,27 @@ object Transactions { case _: SimpleTaprootChannelCommitmentFormat => val scriptTree: ScriptTree.Leaf = Taproot.toRemoteScriptTree(commitKeys.publicKeys) RedeemInfo.TaprootScriptPath(NUMS_POINT.xOnly, scriptTree, scriptTree.hash()) + case ZeroFeeCommitmentFormat => + RedeemInfo.P2wpkh(commitKeys.ourPaymentKey.publicKey) } findPubKeyScriptIndex(commitTx, redeemInfo.pubkeyScript) match { case Left(skip) => Left(skip) case Right(outputIndex) => val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex)) val amount = input.txOut.amount - weight2fee(feerate, commitmentFormat.toRemoteWeight) + val csvDelay = commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => 1 + case ZeroFeeCommitmentFormat => 0 + } val tx = Transaction( - version = 2, - txIn = TxIn(input.outPoint, ByteVector.empty, 1) :: Nil, + // Note that we use the same nVersion field as the commit tx here: this allows us to use this transaction + // to also spend the anchor when using v3 to pay the commit fees. + version = commitTx.version, + txIn = TxIn(input.outPoint, ByteVector.empty, csvDelay) :: Nil, txOut = TxOut(amount, localFinalScriptPubKey) :: Nil, lockTime = 0 ) - val unsignedTx = ClaimRemoteDelayedOutputTx(commitKeys, input, tx, commitmentFormat) + val unsignedTx = ClaimRemoteMainOutputTx(commitKeys, input, tx, commitmentFormat) skipTxIfBelowDust(unsignedTx, localDustLimit) } } @@ -995,7 +1087,7 @@ object Transactions { override def sign(): Transaction = { val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toLocalDelay)) val sig = sign(commitKeys.ourDelayedPaymentKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) witnessToLocalDelayedAfterDelay(sig, redeemScript) @@ -1012,7 +1104,7 @@ object Transactions { object ClaimLocalDelayedOutputTx { def createUnsignedTx(commitKeys: LocalCommitmentKeys, commitTx: Transaction, localDustLimit: Satoshi, toLocalDelay: CltvExpiryDelta, localFinalScriptPubKey: ByteVector, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimLocalDelayedOutputTx] = { val redeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toLocalDelay)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -1043,7 +1135,7 @@ object Transactions { override def sign(): Transaction = { val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toRemoteDelay)) val sig = sign(revocationKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) Scripts.witnessToLocalDelayedWithRevocationSig(sig, redeemScript) @@ -1060,7 +1152,7 @@ object Transactions { object MainPenaltyTx { def createUnsignedTx(commitKeys: RemoteCommitmentKeys, revocationKey: PrivateKey, commitTx: Transaction, localDustLimit: Satoshi, localFinalScriptPubKey: ByteVector, toRemoteDelay: CltvExpiryDelta, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, MainPenaltyTx] = { val redeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toRemoteDelay)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -1095,9 +1187,11 @@ object Transactions { override def sign(): Transaction = { val sig = sign(revocationKey, sighash, redeemInfo, extraUtxos = Map.empty) val witness = redeemInfo match { + case RedeemInfo.P2wpkh(_) => Script.witnessPay2wpkh(revocationKey.publicKey, der(sig)) case RedeemInfo.P2wsh(redeemScript) => Scripts.witnessHtlcWithRevocationSig(commitKeys, sig, redeemScript) case _: RedeemInfo.TaprootKeyPath => Script.witnessKeyPathPay2tr(sig, sighash) case s: RedeemInfo.TaprootScriptPath => Script.witnessScriptPathPay2tr(s.internalKey, s.leaf, ScriptWitness(Seq(sig)), s.scriptTree) + case RedeemInfo.PayToAnchor => Script.witnessPay2anchor } tx.updateWitness(inputIndex, witness) } @@ -1117,7 +1211,7 @@ object Transactions { case (paymentHash, htlcExpiry) => // We don't know if this was an incoming or outgoing HTLC, so we try both cases. val (offered, received) = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => (RedeemInfo.P2wsh(Script.write(htlcOffered(commitKeys.publicKeys, paymentHash, commitmentFormat))), RedeemInfo.P2wsh(Script.write(htlcReceived(commitKeys.publicKeys, paymentHash, htlcExpiry, commitmentFormat)))) case _: SimpleTaprootChannelCommitmentFormat => @@ -1166,7 +1260,7 @@ object Transactions { override def sign(): Transaction = { val witness = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toRemoteDelay)) val sig = sign(revocationKey, sighash, RedeemInfo.P2wsh(redeemScript), extraUtxos = Map.empty) Scripts.witnessToLocalDelayedWithRevocationSig(sig, redeemScript) @@ -1189,7 +1283,7 @@ object Transactions { feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Seq[Either[TxGenerationSkipped, ClaimHtlcDelayedOutputPenaltyTx]] = { val redeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => val redeemScript = Script.write(toLocalDelayed(commitKeys.publicKeys, toRemoteDelay)) RedeemInfo.P2wsh(redeemScript) case _: SimpleTaprootChannelCommitmentFormat => @@ -1201,7 +1295,7 @@ object Transactions { val input = InputInfo(OutPoint(htlcTx, outputIndex), htlcTx.txOut(outputIndex)) val amount = input.txOut.amount - weight2fee(feerate, commitmentFormat.claimHtlcPenaltyWeight) val tx = Transaction( - version = 2, + version = htlcTx.version, txIn = TxIn(input.outPoint, ByteVector.empty, 0xffffffffL) :: Nil, txOut = TxOut(amount, localFinalScriptPubKey) :: Nil, lockTime = 0 @@ -1215,7 +1309,6 @@ object Transactions { // @formatter:off sealed trait TxGenerationSkipped case object OutputNotFound extends TxGenerationSkipped { override def toString = "output not found (probably trimmed)" } - private case object OutputAlreadyInWallet extends TxGenerationSkipped { override def toString = "output doesn't need to be claimed, it belongs to our bitcoin wallet (p2wpkh or p2tr)" } case object AmountBelowDustLimit extends TxGenerationSkipped { override def toString = "amount is below dust limit" } private case class CannotUpdateFee(txInfo: ForceCloseTransaction) extends TxGenerationSkipped { override def toString = s"cannot update fee for ${txInfo.desc} transactions" } // @formatter:on @@ -1238,7 +1331,8 @@ object Transactions { def offeredHtlcTrimThreshold(dustLimit: Satoshi, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Satoshi = { commitmentFormat match { case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => dustLimit - case _ => dustLimit + weight2fee(feerate, commitmentFormat.htlcTimeoutWeight) + case ZeroFeeCommitmentFormat => dustLimit + case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => dustLimit + weight2fee(feerate, commitmentFormat.htlcTimeoutWeight) } } @@ -1256,7 +1350,8 @@ object Transactions { def receivedHtlcTrimThreshold(dustLimit: Satoshi, feerate: FeeratePerKw, commitmentFormat: CommitmentFormat): Satoshi = { commitmentFormat match { case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => dustLimit - case _ => dustLimit + weight2fee(feerate, commitmentFormat.htlcSuccessWeight) + case ZeroFeeCommitmentFormat => dustLimit + case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => dustLimit + weight2fee(feerate, commitmentFormat.htlcSuccessWeight) } } @@ -1291,6 +1386,7 @@ object Transactions { // This is not technically a fee (it doesn't go to miners) but it also has to be deduced from the channel initiator's main output. val anchorsCost = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => AnchorOutputsCommitmentFormat.anchorAmount * 2 + case ZeroFeeCommitmentFormat => 0 sat // the anchor amount will be taken from trimmed outputs } txFee + anchorsCost } @@ -1344,10 +1440,12 @@ object Transactions { private def getHtlcTxInputSequence(commitmentFormat: CommitmentFormat): Long = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => 1 // htlc txs have a 1-block delay to allow CPFP carve-out on anchors + case ZeroFeeCommitmentFormat => 0 // the 1-block delay is unnecessary for v3 transactions } def makeCommitTxOutputs(localFundingPublicKey: PublicKey, remoteFundingPublicKey: PublicKey, + commitmentInput: InputInfo, commitmentKeys: CommitmentPublicKeys, payCommitTxFees: Boolean, dustLimit: Satoshi, @@ -1382,7 +1480,7 @@ object Transactions { if (toLocalAmount >= dustLimit) { val redeemInfo = commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => + case _: AnchorOutputsCommitmentFormat | ZeroFeeCommitmentFormat => RedeemInfo.P2wsh(toLocalDelayed(commitmentKeys, toSelfDelay)) case _: SimpleTaprootChannelCommitmentFormat => val toLocalTree = Taproot.toLocalScriptTree(commitmentKeys, toSelfDelay) @@ -1395,6 +1493,8 @@ object Transactions { val redeemInfo = commitmentFormat match { case _: AnchorOutputsCommitmentFormat => RedeemInfo.P2wsh(toRemoteDelayed(commitmentKeys)) + case ZeroFeeCommitmentFormat => + RedeemInfo.P2wpkh(commitmentKeys.remotePaymentPublicKey) case _: SimpleTaprootChannelCommitmentFormat => val scripTree = Taproot.toRemoteScriptTree(commitmentKeys) RedeemInfo.TaprootScriptPath(NUMS_POINT.xOnly, scripTree, scripTree.hash()) @@ -1412,6 +1512,13 @@ object Transactions { val redeemInfo = ClaimRemoteAnchorTx.redeemInfo(remoteFundingPublicKey, commitmentKeys, commitmentFormat) outputs.append(ToRemoteAnchor(TxOut(AnchorOutputsCommitmentFormat.anchorAmount, redeemInfo.pubkeyScript))) } + case ZeroFeeCommitmentFormat => + // The shared anchor amount is usually 0 sat (ephemeral dust), but when outputs are trimmed, or millisatoshi + // amounts are truncated to satoshis, the corresponding value goes to the anchor output. + // However, we cap this value at 240 sat (which makes the output non-ephemeral) and let the remaining dust + // directly go to on-chain fees. + val anchorAmount = (commitmentInput.txOut.amount - outputs.map(_.txOut.amount).sum).min(ZeroFeeCommitmentFormat.maxAnchorAmount) + outputs.append(ToSharedAnchor(TxOut(anchorAmount, RedeemInfo.PayToAnchor.pubkeyScript))) } outputs.sortWith(CommitmentOutput.isLessThan).toSeq @@ -1422,11 +1529,15 @@ object Transactions { localPaymentBasePoint: PublicKey, remotePaymentBasePoint: PublicKey, localIsChannelOpener: Boolean, + commitmentFormat: CommitmentFormat, outputs: Seq[CommitmentOutput]): CommitTx = { val txNumber = obscuredCommitTxNumber(commitTxNumber, localIsChannelOpener, localPaymentBasePoint, remotePaymentBasePoint) val (sequence, lockTime) = encodeTxNumber(txNumber) val tx = Transaction( - version = 2, + version = commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => 2 + case ZeroFeeCommitmentFormat => 3 + }, txIn = TxIn(commitTxInput.outPoint, ByteVector.empty, sequence = sequence) :: Nil, txOut = outputs.map(_.txOut), lockTime = lockTime @@ -1566,7 +1677,7 @@ object Transactions { val updatedTx = txInfo.tx.copy(txOut = txInfo.tx.txOut.headOption.map(_.copy(amount = txInfo.amountIn - fee)).toSeq) txInfo match { case txInfo: ClaimLocalDelayedOutputTx => Right(txInfo.copy(tx = updatedTx)) - case txInfo: ClaimRemoteDelayedOutputTx => Right(txInfo.copy(tx = updatedTx)) + case txInfo: ClaimRemoteMainOutputTx => Right(txInfo.copy(tx = updatedTx)) // Anchor transaction don't have any output: wallet inputs must be used to pay fees. case txInfo: ClaimAnchorTx => Left(CannotUpdateFee(txInfo)) // HTLC transactions are pre-signed, we can't update their fee by lowering the output amount. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala index 82a9cd32a9..79aab2e859 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala @@ -82,6 +82,7 @@ private[channel] object ChannelCodecs5 { .typecase(0x02, provide(Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)) .typecase(0x03, provide(Transactions.PhoenixSimpleTaprootChannelCommitmentFormat)) .typecase(0x04, provide(Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat)) + .typecase(0x05, provide(Transactions.ZeroFeeCommitmentFormat)) // 0x00 was used for pre-anchor channels, which have been deprecated after eclair v0.13.1. .typecase(0x00, fail[Transactions.CommitmentFormat](Err("some of your channels are not using anchor outputs: you must restart with your previous eclair version and close those channels before updating to this version of eclair (see the release notes for more details)"))) diff --git a/eclair-core/src/test/resources/bolt3-tx-test-vectors-zero-fee-commitment-format.json b/eclair-core/src/test/resources/bolt3-tx-test-vectors-zero-fee-commitment-format.json new file mode 100644 index 0000000000..21cb15f76e --- /dev/null +++ b/eclair-core/src/test/resources/bolt3-tx-test-vectors-zero-fee-commitment-format.json @@ -0,0 +1,373 @@ +{ + "local_funding_priv": "8f567cb6382507019349a47623902aa65d7a142ac85462eeb63dc11799ac2bb9", + "remote_funding_priv": "4d22d96f0c0ccecffee4554d20ed43e51235917508ee292d281235bb7ebe0e3e", + "funding_txid": "4b70a2ee47b3005a6316ff87055e94c6b3d433d0fd3b384c9ecf7813843c1eae", + "funding_index": 1, + "funding_amount_satoshis": 10000000, + "commitment_number": 42, + "to_self_delay": 720, + "local_payment_basepoint_secret": "94f29d20a225ea2f7093331ba0f0f28a9382d8ed08e1fd121329925cd0c01b6d", + "local_delayed_payment_basepoint_secret": "e9d4e1935bf16e948d76ad007baf0646df023af38f41bcf2c8799336949d291e", + "local_htlc_basepoint_secret": "f699038ef4f95b6b16b22a5c04fcb3c508d68d02cd2f86cf197e0fac451681b0", + "per_commitment_point": "0275d12130c276b4274358a328901f8fc47e6c72629102e4b46c9f27dd2c1dda98", + "remote_payment_basepoint_secret": "580bff39085f3a6ae8b1f32905e67366c522ea8f2418391145b2e98f1a7cb3f2", + "remote_htlc_basepoint_secret": "32df9c4dd46ab6210e74e81e15282106f8db883f45674eabb3324166c6513062", + "revocation_basepoint": "026788d019ed90149cbc9aa5ff26dd7f1a6d3cd1bee8bf36cf7d8310fbd3606b14", + "payment_hash_to_preimage": { + "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f": "d3ad493d19860e4744491cf0795c0bc96a8e2a49ebb30afadae7a7d077aa93b2", + "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a": "108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa", + "1a04764fd402b5557cba89ec3c4d8931b0225d6436923c65c079ce64a55084c4": "ec8f390e2ba4b8d807f1f83d25892169aecf63222f895469d04b521ccb9809dc", + "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf": "792dc27fc2bb0512bf2a203153f01d64458ca1dc3aea76f95e95399440120374", + "10b879729e8ddd44f2cfcf3cad6d62be535ca74e293c5ed4a59bd0dcbdad7ca1": "c916e086a4cd7d40f198708aefadd31149da628f820ca2fc213af10f7668501c", + "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983": "5591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c5" + }, + "tests": [ + { + "name": "Commitment transaction without HTLCs, both outputs untrimmed", + "dust_limit_satoshis": 500, + "to_local_msat": 8000000000, + "to_remote_msat": 2000000000, + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800300000000000000000451024e7380841e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea00127a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0400483045022100a05afcdfaf045a0a7b6adb194dcda430bb8e47db8c6d1536b2a94bd98fed77ed02206d580dcd5cba42aebed39515fbd00fdd90480825ae23cce87eef1f1711e2125e0148304502210094afa18972599f7a78b06467bd11d742875baf74aa9e516a775720564671fd8e02206b9ed5f48fb92a1c19543d67f29ee3bd43afee1ec1af6f7f9b4e3371beb8216201475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + }, + { + "name": "Commitment transaction without HTLCs, one output trimmed below maximum anchor amount", + "dust_limit_satoshis": 330, + "to_local_msat": 9999800000, + "to_remote_msat": 200000, + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef8002c8000000000000000451024e73b895980000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0400483045022100a13c79500a9b30eba7af13418816b54aea1da0bdf41c0aa10f53a29017080d5602201a11bcc10f99c4334f778ea94e83db63d2409ff10e9e95b4260ac0dba27a490f014730440220706abbc90e9ab70a7e1f0f28b24baf2f105e49b7a41bf3b5e694f060806fc54402206020a5e51e437c027610a59f0252ac075dfb2b8e5b45f49149e9532935ba5e7a01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + }, + { + "name": "Commitment transaction without HTLCs, one output trimmed above maximum anchor amount", + "dust_limit_satoshis": 15000, + "to_local_msat": 9990000000, + "to_remote_msat": 10000000, + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef8002f0000000000000000451024e73706f980000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402204042ce57689bb7e52af7fb9ec28d6610674ce00e5e438bb1119acfb91aad6386022065de45c4fe52d86249d80397f2c85e143550cb14b3de0cd1e6a54d0622a2bbd4014830450221009fb4e444e9fe2d7db0f867704745c8ea2e4e1018b5986fd8cb9d9facdc1bb1be02202d6589a20ea8a8e3594eac3c99bad523139a36e313a66c6302e619786cb4239601475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + }, + { + "name": "Commitment transaction with all HTLCs above dust limit", + "dust_limit_satoshis": 5000, + "to_local_msat": 7925000000, + "to_remote_msat": 1985000000, + "incoming_htlcs": [ + { + "id": 1, + "amount_msat": 5000000, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920150 + }, + { + "id": 2, + "amount_msat": 5000000, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920150 + }, + { + "id": 5, + "amount_msat": 5000000, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920150 + } + ], + "outgoing_htlcs": [ + { + "id": 5, + "amount_msat": 25000000, + "payment_hash": "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + "cltv_expiry": 920141 + }, + { + "id": 8, + "amount_msat": 25000000, + "payment_hash": "10b879729e8ddd44f2cfcf3cad6d62be535ca74e293c5ed4a59bd0dcbdad7ca1", + "cltv_expiry": 920141 + }, + { + "id": 13, + "amount_msat": 25000000, + "payment_hash": "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + "cltv_expiry": 920141 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800900000000000000000451024e73881300000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dc881300000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dc881300000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dca8610000000000002200200963a1f3e47b8d2a35664f70de49d3331b4499da201004868a9104271f0cd93ba8610000000000002200205b138be4da633f087be191275d3ce30828ae32d1630853fcbf66d22ca6fe2636a8610000000000002200205b138be4da633f087be191275d3ce30828ae32d1630853fcbf66d22ca6fe2636e8491e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea08ed780000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a04004830450221008e951c6305b8d661de280234019d664d7a706b6dc22c23f5488b6b141480d1c202207975f2c477d6fb26ac51b9aa81c1d2b35bb724cebbf21a63e5d7ef3d6e273baf01483045022100ea978743401f2834d516ea434c0396c919e22465b4d2e359e61e6783535a89b30220475e2dbcc204016472b8eb9437e237a26ca102c1aa16812baa7ae522cc6291e801475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [ + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c0135010000000000000000018813000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100b82fff4e0652b305cc93f5f8e45362c55591c9ddb8e05a168c5df177ad8ff7ba022061827439423e660099d4c924257314656939b36180bf21b3a253bdd56f9f2b6b83473044022024832648de0dc603b955b7b6a8ada64cc305affaf7357b018f481fb7e0c48c8d0220716568bee37589c5b67c941768fb10e10f48c1cc66b1128664455b99749a7c2a0120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503560a0eb175ac686800000000", + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c0135020000000000000000018813000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100c25f58229c0f9f985ef85235877268aad5da9cdcbceeb4425e668c97df2bfc920220053bca5957e55cdaa0734ba9a1b7a3eb0bdeced1d4496196d9d9de8ff1b20d2e83483045022100d2f99843a7b29ec1b5044d2dc99f118d25157cdfc07213975a3e0bbc57f22e0a022000ac6b2dab53f34877c376d074d433c599721ea58c113bb7d2b7a540abbaab790120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503560a0eb175ac686800000000", + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c0135030000000000000000018813000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100948054ef219ec5af7494224f65ad6fab9cfd415d7260cb70a4719e99d421871f02205a41957a428b7820cef844c41cd06d8443f55953e000f4e40658175544a012ce83483045022100efab186c9f98ff2326c56d26e0a4f0f58afc94fc241faae4334a944d277d7f3802207a79eb3f17fa874eca67c4245b670e51ca8d89d3bc02b2a8712ec28e71479eba0120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503560a0eb175ac686800000000" + ], + "signed_htlc_timeout_txs": [ + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c013504000000000000000001a861000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100f2890a34be31987e4cc3e17f71d5174bb85121e57bb0105e8aa6793f6f057a42022000d0e11ffb3217bb91f1b8babb43e2726889cf3281bfaffa3b3b1c9970fa71098347304402200373b0e0533fa0140ae10df40b93bb599920ce582d0a9d07398499554591659e02207f6ef44097b4e850bae0a4b5d6e0778a056e32ccd403ea88c62004d84e91a0af01008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914504170790db95d43716b136806e6a0fdf06e39e488ac68684d0a0e00", + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c013505000000000000000001a861000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a05004730440220450536f38c6fde3e8777ad9c6d0a505ff44812de86fc82f0f2a0f4ce11f6ac7a022053306d3739ddd8a5cd8e2acba63b2076927200701762b314fb83faafcfcc389383483045022100e506e7ccb18b31fd3785477a3cfd885d7c21bf6eb9f5368cb321913208ae8e31022074c186967bf90a5583b46f445a6cb12fb6f2bf83805e048b486b8da29a553a4601008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488ac68684d0a0e00", + "03000000000101c3b4fad51418c874498af12060d49477c154845040e3584104ae641c542c013506000000000000000001a861000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500473044022061b73271a1b6b5a1bd8e15c58b08320c72da0a1db19493abbfc6f74b5fa80c2c022030ff309d3413b9661fc8f8f2b5fd14bc733c239cc7ba8b971fd562263e62bf3b8347304402207559852cd036af82658949c28e4922f7351756616f8d04703e8060889b4b484e022050d997e1684826dc61bf0d88d9d08c01b8fcf67958ac7a177c320a1a70305fb001008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488ac68684d0a0e00" + ] + }, + { + "name": "Commitment transaction with all HTLCs above dust limit and millisatoshi truncation", + "dust_limit_satoshis": 5000, + "to_local_msat": 7949998969, + "to_remote_msat": 1983999030, + "incoming_htlcs": [ + { + "id": 7, + "amount_msat": 10000650, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920150 + }, + { + "id": 9, + "amount_msat": 6000320, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920150 + } + ], + "outgoing_htlcs": [ + { + "id": 5, + "amount_msat": 25000821, + "payment_hash": "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + "cltv_expiry": 920141 + }, + { + "id": 8, + "amount_msat": 25000210, + "payment_hash": "10b879729e8ddd44f2cfcf3cad6d62be535ca74e293c5ed4a59bd0dcbdad7ca1", + "cltv_expiry": 920141 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800703000000000000000451024e73701700000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dc102700000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dca8610000000000002200200963a1f3e47b8d2a35664f70de49d3331b4499da201004868a9104271f0cd93ba8610000000000002200205b138be4da633f087be191275d3ce30828ae32d1630853fcbf66d22ca6fe2636ff451e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152eaae4e790000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402207077d60a004f42171b99c66b1b6b1799910d8f2cb72b0ef6d6500cd2f937c28a022016464954f0c05ae083bf559bc603c89f014006c44258d054a06ded667166a1f701483045022100cb78620afa0efd40d480286b59545e2e00379e78f6fb8c31d0d3659880176ed102201218323fbc8e0cd1184c7a164fad4cecb841f3eb75a3fc7d11208acbcb66386501475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [ + "0300000000010122daaec93bca3ee6bee9c78cf7885e538a56d0cbd63e3df086bbac22b4f5455d010000000000000000017017000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100d53dbf9e7479d48322eda78cd96c0d3d6a53c55c7c0d629a2b11e1b1a02d6b95022031a06dda013ee5a26db181552499816de0a9786c71cbefd1614f8b2e6bcb91318347304402204e4fd2e36058ed515f36cc609e636989fa89bf0b1d0e968fe642fcca7e2ceb1e022055a69e1e9be2799ae57ebae59792c9f50ca3943e6c154b4b3aabce6b080009300120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503560a0eb175ac686800000000", + "0300000000010122daaec93bca3ee6bee9c78cf7885e538a56d0cbd63e3df086bbac22b4f5455d020000000000000000011027000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a050047304402206556f3ae8fb62fd764f1a65fcc238bfc7cd629872c52ba7ee7b91e34aed4f9de022008545d9f5477371f940d8719270cc1b22d04497f53a97bf75dfb6a9ec5e5b49e8347304402205aa6b8143ef46e644157c4f4ad2ff439ae3c2e0cd76c2d3050aa6a1f5a31d33d022072f430949c2996233c5e832c3957f4f9bbf5586af3d62ebf3c0621cf92511b1a0120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503560a0eb175ac686800000000" + ], + "signed_htlc_timeout_txs": [ + "0300000000010122daaec93bca3ee6bee9c78cf7885e538a56d0cbd63e3df086bbac22b4f5455d03000000000000000001a861000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a05004830450221009d775b6e196693171a41c69337de17bd6c7846dd677d0f9db8595faedb3f621f022060eb980ee28effaa38515b31691ec522fef90db0f6b83c07cf3cf3aebc1302c083483045022100e93e04871634a1eded25a6721bb6005787e92cfea09831f7af99433891d5373202204ec63ff8fd536518eca98e068d05d562d7be9465cb4d72a52a35308a4b3d843001008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914504170790db95d43716b136806e6a0fdf06e39e488ac68684d0a0e00", + "0300000000010122daaec93bca3ee6bee9c78cf7885e538a56d0cbd63e3df086bbac22b4f5455d04000000000000000001a861000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100c86ebf7f229be7b621a34a3fac39cfe20ea6b26e078eca782c136e635077b47a02205866ca91ef3eb8bc8dd8190c25d8a70dd101b067cc038d367ef2ee5a67c1beea8347304402203f81e6064b5a57bdc9a8759a0906dd8ee767063878f10133c435198c89c575a2022045d4e28f14aad0be1f03e43b3c6bd994d95b18b7ee979b4166c50659d4c02fa601008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488ac68684d0a0e00" + ] + }, + { + "name": "Commitment transaction with dust HTLCs below maximum anchor amount", + "dust_limit_satoshis": 1000, + "to_local_msat": 7979870000, + "to_remote_msat": 1950000000, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 100000, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + }, + { + "id": 1, + "amount_msat": 49900000, + "payment_hash": "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + "cltv_expiry": 920125 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 10000000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920140 + }, + { + "id": 1, + "amount_msat": 130000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920140 + }, + { + "id": 2, + "amount_msat": 10000000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920140 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef8006e6000000000000000451024e73102700000000000022002081cd2904fc5581e6459d9375384af1223e6bd4fd112f3505e87b81e622a48cb7102700000000000022002081cd2904fc5581e6459d9375384af1223e6bd4fd112f3505e87b81e622a48cb7ecc20000000000002200207c2edfefe2690c02efde0c461b8a9283ed2a68968109306be4cb5fa848bd7a7630c11d0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea5ec3790000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0400483045022100cb7b6ae16b80c1ad74baea6f37be3581ca60915da75a03be3b377493f3359cc302203b591047f669cce31215c8a5a6039d5fd8dbfd8d6b53af39a619e6541d6bcf3101483045022100a661444a0800fe5e8bf9ecdef3631610e834485503b8e58d64d08ed5e88ebb2d0220248944d134cadf36cef90d05785ece9432727e81f0f66958a903baa0d69061cd01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [ + "03000000000101d8211cfec4c28b189edcf7b12b739bfbc0f77e44cc6fab67e50fa23cc78481c503000000000000000001ecc2000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100bbafdb3eeb4ab737859798753e0685f203294b6225c1300c9db6867a6f5fde180220769ca281abe51e93d707262ab3f45c54f5ec0ad3529836de0536b24e635e1c9f8347304402200f60ba0673e03a25cd8ab2f2d8fe4e0bc3ac7ffafe932e7a76afb3be7189b4f50220541a02e557d7ff2ccf0f5be4fa22291d07ecf67d8666dffba55e76ecbb5b9bf901205591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c58b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae6775033d0a0eb175ac686800000000" + ], + "signed_htlc_timeout_txs": [ + "03000000000101d8211cfec4c28b189edcf7b12b739bfbc0f77e44cc6fab67e50fa23cc78481c5010000000000000000011027000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a05004830450221009beeb028f6de6f925a986f3aaac7931bd46779b5a2dc8e82ae0509cf0174468002205a2761d34b276752f7f6e62c013c9509104a831df1f4b91a692bb8ad48285d3183483045022100c04dd0b08fd50bfc921de2a5d09840aab97b116e95ffd09cc11470404280be1202204aee1cd187f0074c5b80bae98981d232168daba5bde67adc7539b1a55a5a670901008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a91489baf9f76be304292b0314ffa9c61eb794b05dd188ac68684c0a0e00", + "03000000000101d8211cfec4c28b189edcf7b12b739bfbc0f77e44cc6fab67e50fa23cc78481c5020000000000000000011027000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a050047304402203a6ed1dc27298cfb030a5ff0f237b5288e263bd3cfe13ced890f3559e7c25db10220713167cc374f850a2f414dd8988f87cbfe978ba56b8c5cf690aa3c2d357a174c83483045022100c2f636b397f0fd6731b04323a0743aee902f01ac2e8bdac72548ab5380fecd0f02206ee5c57fac4c5255018bc687ae46a97a9781b7d87722d3c73b8bc93d951f713f01008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a91489baf9f76be304292b0314ffa9c61eb794b05dd188ac68684c0a0e00" + ] + }, + { + "name": "Commitment transaction with similar dust HTLCs below maximum anchor amount", + "dust_limit_satoshis": 546, + "to_local_msat": 7999880000, + "to_remote_msat": 1999940000, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 30000, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + }, + { + "id": 1, + "amount_msat": 30000, + "payment_hash": "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + "cltv_expiry": 920125 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 30000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + }, + { + "id": 1, + "amount_msat": 30000, + "payment_hash": "1a04764fd402b5557cba89ec3c4d8931b0225d6436923c65c079ce64a55084c4", + "cltv_expiry": 920125 + }, + { + "id": 2, + "amount_msat": 30000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + }, + { + "id": 3, + "amount_msat": 30000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef8003b4000000000000000451024e7344841e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea88117a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0400473044022034f22696dd7501d65fc117af3edcfed535eb301317fe35eef08658b571fca4d0022030ff6276cff9e22968f8b08f92a1d4f2c37fe4da29de095df4841d32bdaa501b014830450221009c820c376715329110045b3cf90f59838fd2f86b16774cb5ccccbd6a62830d9602205f2d970bef2589a5e37035937ab7db0461223e429b943b38ecf577e32b7b457201475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [], + "signed_htlc_timeout_txs": [] + }, + { + "name": "Commitment transaction with millisatoshi dust HTLCs adding to less than 1 satoshi", + "dust_limit_satoshis": 330, + "to_local_msat": 7999978526, + "to_remote_msat": 1999970475, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 29525, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 21474, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800334000000000000000451024e7362841e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152eaea117a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402201368dc415e11647edbfc1faaaccbe4717e5fdb88c4220a0bb19969781c9f757f02203fad3a7578fcfdc428b527b97f918b639ff07941b3b42c44f58b1364fdc57177014730440220338a338e0d477b63d2a7a00baca06c45b74bdd63d02e7d198cd8435180e9317e022014d0b95c9fde3409678417ee1a71d1d4d755f28db66a83eb10bea56de83d628d01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [], + "signed_htlc_timeout_txs": [] + }, + { + "name": "Commitment transaction with millisatoshi dust HTLCs adding to 1 satoshi", + "dust_limit_satoshis": 330, + "to_local_msat": 7999978525, + "to_remote_msat": 1999970475, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 29525, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 21475, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800334000000000000000451024e7362841e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152eaea117a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402201368dc415e11647edbfc1faaaccbe4717e5fdb88c4220a0bb19969781c9f757f02203fad3a7578fcfdc428b527b97f918b639ff07941b3b42c44f58b1364fdc57177014730440220338a338e0d477b63d2a7a00baca06c45b74bdd63d02e7d198cd8435180e9317e022014d0b95c9fde3409678417ee1a71d1d4d755f28db66a83eb10bea56de83d628d01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [], + "signed_htlc_timeout_txs": [] + }, + { + "name": "Commitment transaction with millisatoshi dust HTLCs adding to more than 1 satoshi", + "dust_limit_satoshis": 330, + "to_local_msat": 7999978288, + "to_remote_msat": 1999970247, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 29753, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 21712, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920125 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800334000000000000000451024e7362841e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152eaea117a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402201368dc415e11647edbfc1faaaccbe4717e5fdb88c4220a0bb19969781c9f757f02203fad3a7578fcfdc428b527b97f918b639ff07941b3b42c44f58b1364fdc57177014730440220338a338e0d477b63d2a7a00baca06c45b74bdd63d02e7d198cd8435180e9317e022014d0b95c9fde3409678417ee1a71d1d4d755f28db66a83eb10bea56de83d628d01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [], + "signed_htlc_timeout_txs": [] + }, + { + "name": "Commitment transaction with dust HTLCs above maximum anchor amount", + "dust_limit_satoshis": 2500, + "to_local_msat": 7988000000, + "to_remote_msat": 1988000000, + "incoming_htlcs": [ + { + "id": 0, + "amount_msat": 2000000, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920125 + }, + { + "id": 1, + "amount_msat": 5000000, + "payment_hash": "ffa4f37b6d7dc03fecaaed8d36ebbea6d199e820e386e4549a69ce733f39f29f", + "cltv_expiry": 920130 + }, + { + "id": 2, + "amount_msat": 5000000, + "payment_hash": "23877c9093799487a8d49c7d6aaff7e06b9831c547400605252e7b07f7ae638a", + "cltv_expiry": 920130 + } + ], + "outgoing_htlcs": [ + { + "id": 0, + "amount_msat": 1000000, + "payment_hash": "1a04764fd402b5557cba89ec3c4d8931b0225d6436923c65c079ce64a55084c4", + "cltv_expiry": 920125 + }, + { + "id": 1, + "amount_msat": 1000000, + "payment_hash": "29a74a69c5941d402838f7e1a95c2b2ec534d79524b2582f48df7bc519ebaecf", + "cltv_expiry": 920130 + }, + { + "id": 2, + "amount_msat": 10000000, + "payment_hash": "10b879729e8ddd44f2cfcf3cad6d62be535ca74e293c5ed4a59bd0dcbdad7ca1", + "cltv_expiry": 920130 + } + ], + "signed_commit_tx": "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef8006f0000000000000000451024e7388130000000000002200202cb7b4a1ac1ca2aa06918f6933a9bf3a6ab777abfca18ac22fb4eeec587faf048813000000000000220020fd9f3b68e5a818a53f020ab09d1c956ead78f28b3c22950dc0d73568f797c92210270000000000002200200963a1f3e47b8d2a35664f70de49d3331b4499da201004868a9104271f0cd93ba0551e0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea20e3790000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0400483045022100f706457b6f58634a64d86a403b622018d1cdf08064f08eaaf90ed47cbdfc81f002200b23d77b8151d0e907850d313b87cf53f0a1e8aef871cbb5ec3ef7f3af51326101483045022100cf3f7201781764693b8bb2db1a703c62fe9cb992103d4d950e9d1806e2e4c49202203993f5bfcc1f91617d48f22a10f2400ca298f42499d8389d4396bcad8750e88301475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20", + "signed_htlc_success_txs": [ + "0300000000010142bb3321b077161ea560097e1b149edef504e00f1e2c6a77bbe4ad2aed6fde07010000000000000000018813000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500473044022072f786f54d93d9b1780bd3c605a434a2ae097852c6af75b18a23ea3e7cb995de02205608d310544bed398c406cf8f6cdeadbf119a8808c38415dbc93c82733168fc683483045022100e5e66a86053e6adda1b02786d9e8b9b7057026b8d81263fe5414d6b7660e70c7022055ff89300da995e2c8122e997338dac2d5ea2f526dcb243b9e7c1eb380155e100120d3ad493d19860e4744491cf0795c0bc96a8e2a49ebb30afadae7a7d077aa93b28b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a91416a189e9fcfe9edc8c6930da3dd87cebf44045a388527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503420a0eb175ac686800000000", + "0300000000010142bb3321b077161ea560097e1b149edef504e00f1e2c6a77bbe4ad2aed6fde07020000000000000000018813000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a05004730440220444371784d52a13dedec1baedd0a5f10cc78727a873614cf6981696e0ef9d28502202961336e7e0cbb530c4e5dda0a628bc21b5ce8e3f1e6274bd7ee20cf7b6974218347304402205b1693a32569a757d0928c163aae56a3986e3d53741ba0a9db5aac1289752aa3022002c8ef3e377a2461800e98cbfc40b31511d39e02473152943d959dcfd55e7c9e0120108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa8b76a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c8201208763a914f4c8e88504a23ed3e390ea80300c834f0eb79a6c88527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae677503420a0eb175ac686800000000" + ], + "signed_htlc_timeout_txs": [ + "0300000000010142bb3321b077161ea560097e1b149edef504e00f1e2c6a77bbe4ad2aed6fde07030000000000000000011027000000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a0500483045022100eee7c19343ad58d9381a808fcd14af0c1e05bd56bd16c25c5ad8980af68b2df9022046acc611280afabcd82ef4a256d1f42be6618bc12d83bcd138141fb4915d256f83483045022100e6c8d81693cd3a03db0777b31e9318f8b8eb5f275817116dce8a72064d096bee02206439ddcac93a2756e1079e955245e2ac1924d874ba8388de9d497e4286cc20cb01008576a914ef8968bbfe34ad740642784d7b1efaebfd5b23ec8763ac672103d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0b7c820120876475527c2103c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c652ae67a914504170790db95d43716b136806e6a0fdf06e39e488ac6868420a0e00" + ] + } + ] +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala index a7fa658f78..df5a53fd86 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala @@ -128,7 +128,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! WatchFundingSpentTriggered(bobCommitTx) // In response to that, alice publishes her claim txs. alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main") val mainAmount = bobCommitTx.txOut(claimMain.input.index.toInt).amount val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo) assert(claimHtlcTxs.collect { case tx: ClaimHtlcSuccessTx => tx }.size == 1) @@ -185,7 +185,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! WatchFundingSpentTriggered(bobCommitTx) // In response to that, alice publishes her claim txs alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main") val mainAmount = bobCommitTx.txOut(claimMain.input.index.toInt).amount (1 to 2).map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) alice ! WatchTxConfirmedTriggered(BlockHeight(600_000), 5, bobCommitTx) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index d0234a8ff4..950f9d0d5b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -114,7 +114,7 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat val rcp = bob.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get rcp.anchorOutput_opt.foreach(_ => bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx]) - rcp.localOutput_opt.foreach(_ => bob2blockchain.expectFinalTxPublished("remote-main-delayed")) + rcp.localOutput_opt.foreach(_ => bob2blockchain.expectFinalTxPublished("remote-main")) // Bob is missing the preimage for 2 of the HTLCs she received. assert(rcp.htlcOutputs.size == 6) val claimHtlcTxs = (0 until 4).map(_ => bob2blockchain.expectMsgType[PublishReplaceableTx]) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index e3f8222782..6ac9f58a45 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -21,13 +21,13 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp -import fr.acinq.bitcoin.scalacompat.{Block, BtcAmount, MilliBtcDouble, MnemonicCode, OutPoint, SatoshiLong, ScriptElt, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{Block, BtcAmount, MilliBtcDouble, MnemonicCode, OutPoint, SatoshiLong, Script, ScriptElt, Transaction, TxId} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient} -import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw, FeeratesPerKw} +import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerByte, FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, OnChainAddressCache} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel @@ -159,6 +159,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val setup = init(aliceNodeParams, TestConstants.Bob.nodeParams.copy(blockHeight = blockHeight), walletA_opt = Some(walletClient)) val testTags = channelType match { case _: ChannelTypes.AnchorOutputs => Set(ChannelStateTestsTags.AnchorOutputsPhoenix) + case _: ChannelTypes.ZeroFeeCommitments => Set(ChannelStateTestsTags.ZeroFeeCommitments, ChannelStateTestsTags.NoMaxHtlcValueInFlight) case _ => Set.empty[String] } reachNormal(setup, testTags) @@ -227,6 +228,33 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("commit tx feerate high enough, not spending anchor output (local commit, zero-fee commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + // We create many dust HTLCs, which will contribute to the commit feerate. + (1 to 10).foreach(_ => addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice)) + crossSign(alice, bob, alice2bob, bob2alice) + + // The current network feerate is lower than what the commit tx already pays. + setFeerate(FeeratePerByte(1 sat).perKw) + val commitTx = alice.signCommitTx() + assert(commitTx.txOut.exists(o => o.publicKeyScript == Script.write(Script.pay2anchor) && o.amount == ZeroFeeCommitmentFormat.maxAnchorAmount)) + assert(Transactions.fee2rate(alice.commitments.latest.capacity - commitTx.txOut.map(_.amount).sum, commitTx.weight()) >= FeeratePerByte(1 sat).perKw) + + probe.send(alice, CMD_FORCECLOSE(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + val anchorTx = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(aliceBlockHeight() + 20)) + assert(anchorTx.commitTx == commitTx) + assert(anchorTx.txInfo.isInstanceOf[ClaimLocalAnchorTx]) + publisher ! Publish(probe.ref, anchorTx) + + val result = probe.expectMsgType[TxRejected] + assert(result.cmd == anchorTx) + assert(result.reason == TxSkipped(retryNextBlock = true)) + } + } + test("commit tx feerate high enough, not spending anchor output (remote commit)") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ @@ -242,6 +270,38 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("commit tx feerate high enough, main output does not spend anchor output (remote commit, zero-fee commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + // We create many dust HTLCs, which will contribute to the commit feerate. + (1 to 10).foreach(_ => addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice)) + crossSign(alice, bob, alice2bob, bob2alice) + + // The current network feerate is lower than what the commit tx already pays. + setFeerate(FeeratePerByte(1 sat).perKw) + val commitTx = bob.signCommitTx() + assert(commitTx.txOut.exists(o => o.publicKeyScript == Script.write(Script.pay2anchor) && o.amount == ZeroFeeCommitmentFormat.maxAnchorAmount)) + assert(Transactions.fee2rate(bob.commitments.latest.capacity - commitTx.txOut.map(_.amount).sum, commitTx.weight()) >= FeeratePerByte(1 sat).perKw) + + probe.send(alice, WatchFundingSpentTriggered(commitTx)) + val publishMain = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishMain.commitTx == commitTx) + assert(publishMain.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishMain.txInfo.isInstanceOf[ClaimRemoteMainOutputTx]) + + val listener = TestProbe() + system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) + + publisher ! Publish(probe.ref, publishMain) + val mainTx = listener.expectMsgType[TransactionPublished].tx + assert(mainTx.version == 3) + assert(mainTx.txIn.map(_.outPoint.txid).toSet == Set(commitTx.txid)) + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).toSet == Set(commitTx.txid, mainTx.txid)) + } + } + test("commit tx recently confirmed, not spending anchor output") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ @@ -457,6 +517,47 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("commit tx feerate too low, spending anchor output (local commit, zero-fee-commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + val commitTx = alice.signCommitTx() + probe.send(alice, CMD_FORCECLOSE(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + + // Forward the anchor tx to the publisher. + val anchorTx = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(aliceBlockHeight() + 30)) + assert(anchorTx.commitTx == commitTx) + assert(anchorTx.txInfo.isInstanceOf[ClaimLocalAnchorTx]) + + val targetFeerate = FeeratePerKw(3000 sat) + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target what's provided. + setFeerate(targetFeerate, blockTarget = 12) + publisher ! Publish(probe.ref, anchorTx) + + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(commitTx.txid)) + + // we check that the anchor tx contains additional wallet inputs + // there are 2 transactions in the mempool, the one that is not the commit tx has to be the anchor tx + wallet.getTransaction(mempoolTxs.filterNot(_.txid == commitTx.txid).head.txid).pipeTo(probe.ref) + val publishedAnchorTx = probe.expectMsgType[Transaction] + assert(publishedAnchorTx.txIn.size > 1) + + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs.map(_.weight).sum.toInt) + val actualFee = mempoolTxs.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + + generateBlocks(6) + system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) + val result = probe.expectMsgType[TxConfirmed] + assert(result.cmd == anchorTx) + assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.txid)) + assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) + } + } + private def testSpendRemoteCommitAnchor(f: Fixture, nextCommit: Boolean): Unit = { import f._ @@ -630,6 +731,77 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("remote commit tx not published, publishing it and spending anchor output (zero-fee-commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + // Alice puts all of her balance in an HTLC to Bob: she won't have enough funds in her main output to pay the commit fee. + addHtlc(alice.commitments.availableBalanceForSend, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val commitTx = bob.signCommitTx() + probe.send(alice, WatchFundingSpentTriggered(commitTx)) + val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishAnchor.commitTx == commitTx) + assert(publishAnchor.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishAnchor.txInfo.isInstanceOf[ClaimRemoteAnchorTx]) + + val targetFeerate = FeeratePerKw(3000 sat) + setFeerate(targetFeerate) + val anchorTx = publishAnchor.copy(confirmationTarget = ConfirmationTarget.Absolute(aliceBlockHeight() + 4)) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(commitTx.txid)) + + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs.map(_.weight).sum.toInt) + val actualFee = mempoolTxs.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + + generateBlocks(6) + system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) + val result = probe.expectMsgType[TxConfirmed] + assert(result.cmd == anchorTx) + assert(result.tx.version == 3) + assert(result.tx.txIn.count(_.outPoint.txid == commitTx.txid) == 1) + assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) + } + } + + test("remote commit tx not published, publishing it and spending anchor output with main output (zero-fee-commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + val commitTx = bob.signCommitTx() + // When using zero-fee commitments, we use our main output to spend the anchor output. + probe.send(alice, WatchFundingSpentTriggered(commitTx)) + val publishMain = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishMain.commitTx == commitTx) + assert(publishMain.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishMain.txInfo.isInstanceOf[ClaimRemoteMainOutputTx]) + + val targetFeerate = FeeratePerKw(3000 sat) + setFeerate(targetFeerate) + val mainTx = publishMain.copy(confirmationTarget = ConfirmationTarget.Absolute(aliceBlockHeight() + 6)) + publisher ! Publish(probe.ref, mainTx) + // wait for the commit tx and main tx to be published + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(commitTx.txid)) + + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs.map(_.weight).sum.toInt) + val actualFee = mempoolTxs.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + + generateBlocks(6) + system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) + val result = probe.expectMsgType[TxConfirmed] + assert(result.cmd == mainTx) + assert(result.tx.version == 3) + assert(result.tx.txIn.size == 2) // main output and anchor output + assert(result.tx.txIn.map(_.outPoint.txid).toSet == Set(commitTx.txid)) + assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) + } + } + test("commit tx feerate too low, spending anchor outputs with multiple wallet inputs") { val utxos = Seq( // channel funding @@ -727,6 +899,100 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } + test("commit tx not confirming, lowering anchor output amount (zero-fee-commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + // Alice puts all of her balance in an HTLC to Bob: she won't have enough funds in her main output to pay the commit fee. + addHtlc(alice.commitments.availableBalanceForSend, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val commitTx = bob.signCommitTx() + probe.send(alice, WatchFundingSpentTriggered(commitTx)) + val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishAnchor.commitTx == commitTx) + assert(publishAnchor.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishAnchor.txInfo.isInstanceOf[ClaimRemoteAnchorTx]) + assert(publishAnchor.confirmationTarget == ConfirmationTarget.Absolute(aliceBlockHeight() + 144)) // one outgoing HTLC that times out in 144 blocks + + val listener = TestProbe() + system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) + + val oldFeerate = FeeratePerKw(3000 sat) + setFeerate(oldFeerate) + publisher ! Publish(probe.ref, publishAnchor) + // wait for the commit tx and anchor tx to be published + val anchorTx1 = listener.expectMsgType[TransactionPublished].tx + assert(anchorTx1.version == 3) + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.txid)) + val mempoolAnchorTx1 = mempoolTxs1.filter(_.txid != commitTx.txid).head + assert(mempoolAnchorTx1.txid == anchorTx1.txid) + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + val newFeerate = FeeratePerKw(5000 sat) + setFeerate(newFeerate, blockTarget = 12) + system.eventStream.publish(CurrentBlockHeight(aliceBlockHeight() + 132)) + val anchorTx2 = listener.expectMsgType[TransactionPublished].tx + assert(anchorTx2.version == 3) + assert(!isInMempool(mempoolAnchorTx1.txid)) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.txid).head + assert(mempoolAnchorTx2.txid == anchorTx2.txid) + assert(mempoolAnchorTx1.fees < mempoolAnchorTx2.fees) + + val targetFee = Transactions.weight2fee(newFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + } + } + + test("commit tx not confirming, lowering main output amount (zero-fee-commitments)") { + withFixture(Seq(500 millibtc), ChannelTypes.ZeroFeeCommitments()) { f => + import f._ + + val commitTx = bob.signCommitTx() + // When using zero-fee commitments, we use our main output to spend the anchor output. + probe.send(alice, WatchFundingSpentTriggered(commitTx)) + val publishMainTx1 = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishMainTx1.commitTx == commitTx) + assert(publishMainTx1.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishMainTx1.txInfo.isInstanceOf[ClaimRemoteMainOutputTx]) + + val listener = TestProbe() + system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) + + val oldFeerate = FeeratePerKw(3000 sat) + setFeerate(oldFeerate) + publisher ! Publish(probe.ref, publishMainTx1) + // wait for the commit tx and main tx to be published + val mainTx1 = listener.expectMsgType[TransactionPublished].tx + assert(mainTx1.version == 3) + assert(mainTx1.txIn.size == 2) // main output and anchor output + assert(mainTx1.txIn.map(_.outPoint.txid).toSet == Set(commitTx.txid)) + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.txid)) + val mempoolMainTx1 = mempoolTxs1.filter(_.txid != commitTx.txid).head + assert(mempoolMainTx1.txid == mainTx1.txid) + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + val newFeerate = FeeratePerKw(5000 sat) + setFeerate(newFeerate, blockTarget = 12) + system.eventStream.publish(CurrentBlockHeight(aliceBlockHeight() + 5)) + val mainTx2 = listener.expectMsgType[TransactionPublished].tx + assert(mainTx2.version == 3) + assert(mainTx2.txIn.map(_.outPoint) == mainTx1.txIn.map(_.outPoint)) + assert(!isInMempool(mainTx1.txid)) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolMainTx2 = mempoolTxs2.filter(_.txid != commitTx.txid).head + assert(mempoolMainTx2.txid == mainTx2.txid) + assert(mempoolMainTx1.fees < mempoolMainTx2.fees) + + val targetFee = Transactions.weight2fee(newFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + } + } + test("commit tx not confirming, adding other wallet inputs") { withFixture(Seq(10.2 millibtc, 0.15 millibtc, 0.15 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ @@ -1603,7 +1869,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(1) val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) 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 a2aa6ae263..57035a35dd 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 @@ -98,6 +98,8 @@ object ChannelStateTestsTags { val OptionSimpleTaprootPhoenix = "option_simple_taproot_phoenix" /** If set, channels will use taproot. */ val OptionSimpleTaproot = "option_simple_taproot" + /** If set, channel will use zero-fee commitments. */ + val ZeroFeeCommitments = "zero_fee_commitments" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -142,7 +144,8 @@ trait ChannelStateTestsBase extends Assertions with Eventually { dualFunded = dualFunded, commitTxFeerate = channelType match { case _: ChannelTypes.AnchorOutputs | ChannelTypes.SimpleTaprootChannelsPhoenix => TestConstants.phoenixCommitFeeratePerKw - case _ => TestConstants.anchorOutputsFeeratePerKw + case _: ChannelTypes.AnchorOutputsZeroFeeHtlcTx | _: ChannelTypes.SimpleTaprootChannelsStaging => TestConstants.anchorOutputsFeeratePerKw + case _: ChannelTypes.ZeroFeeCommitments => FeeratePerKw(0 sat) }, fundingTxFeerate = TestConstants.feeratePerKw, fundingTxFeeBudget_opt = None, @@ -265,6 +268,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.AnchorOutputsPhoenix))(_.removed(Features.AnchorOutputsZeroFeeHtlcTx).updated(Features.AnchorOutputs, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix))(_.removed(Features.SimpleTaprootChannelsStaging).updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional).updated(Features.PhoenixZeroReserve, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaproot))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroFeeCommitments))(_.updated(Features.ZeroFeeCommitments, FeatureSupport.Optional)) ) val nodeParamsB1 = nodeParamsB.copy(features = nodeParamsB.features .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableWumbo))(_.removed(Features.Wumbo)) @@ -277,6 +281,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.AnchorOutputsPhoenix))(_.removed(Features.AnchorOutputsZeroFeeHtlcTx).updated(Features.AnchorOutputs, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix))(_.removed(Features.SimpleTaprootChannelsStaging).updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional).updated(Features.PhoenixZeroReserve, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaproot))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroFeeCommitments))(_.updated(Features.ZeroFeeCommitments, FeatureSupport.Optional)) ) (nodeParamsA1, nodeParamsB1) } @@ -287,7 +292,9 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val scidAlias = canUse(Features.ScidAlias) && !announceChannel // alias feature is incompatible with public channel val zeroConf = canUse(Features.ZeroConf) - if (canUse(Features.SimpleTaprootChannelsStaging)) { + if (canUse(Features.ZeroFeeCommitments)) { + ChannelTypes.ZeroFeeCommitments(scidAlias, zeroConf) + } else if (canUse(Features.SimpleTaprootChannelsStaging)) { ChannelTypes.SimpleTaprootChannelsStaging(scidAlias, zeroConf) } else if (canUse(Features.SimpleTaprootChannelsPhoenix)) { ChannelTypes.SimpleTaprootChannelsPhoenix @@ -636,12 +643,12 @@ trait ChannelStateTestsBase extends Assertions with Eventually { } } - case class PublishedForceCloseTxs(mainTx_opt: Option[Transaction], anchorTx: Transaction, htlcSuccessTxs: Seq[Transaction], htlcTimeoutTxs: Seq[Transaction]) { + case class PublishedForceCloseTxs(mainTx_opt: Option[Transaction], anchorTx_opt: Option[Transaction], htlcSuccessTxs: Seq[Transaction], htlcTimeoutTxs: Seq[Transaction]) { val htlcTxs: Seq[Transaction] = htlcSuccessTxs ++ htlcTimeoutTxs } def localClose(s: TestFSMRef[ChannelState, ChannelData, Channel], s2blockchain: TestProbe, htlcSuccessCount: Int = 0, htlcTimeoutCount: Int = 0): (LocalCommitPublished, PublishedForceCloseTxs) = { - s ! Error(ByteVector32.Zeroes, "oops") + s ! CMD_FORCECLOSE(ActorRef.noSender, None, None) eventually(assert(s.stateName == CLOSING)) val closingState = s.stateData.asInstanceOf[DATA_CLOSING] assert(closingState.localCommitPublished.isDefined) @@ -651,7 +658,11 @@ trait ChannelStateTestsBase extends Assertions with Eventually { assert(localCommitPublished.incomingHtlcs.size >= htlcSuccessCount) assert(localCommitPublished.outgoingHtlcs.size == htlcTimeoutCount) - val commitTx = s2blockchain.expectFinalTxPublished("commit-tx").tx + val commitTx = closingState.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => s2blockchain.expectFinalTxPublished("commit-tx").tx + // In the 0-fee case, we cannot publish the commitment transaction alone. + case ZeroFeeCommitmentFormat => localCommitPublished.commitTx + } assert(commitTx.txid == closingState.commitments.latest.localCommit.txId) val commitInput = closingState.commitments.latest.commitInput(s.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(commitInput.outPoint -> commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -696,7 +707,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { }) // s is now in CLOSING state with txs pending for confirmation before going in CLOSED state - val publishedTxs = PublishedForceCloseTxs(publishedMainTx_opt, publishedAnchorTx, publishedHtlcSuccessTxs, publishedHtlcTimeoutTxs) + val publishedTxs = PublishedForceCloseTxs(publishedMainTx_opt, Some(publishedAnchorTx), publishedHtlcSuccessTxs, publishedHtlcTimeoutTxs) (localCommitPublished, publishedTxs) } @@ -715,10 +726,17 @@ trait ChannelStateTestsBase extends Assertions with Eventually { assert(remoteCommitPublished.outgoingHtlcs.size == htlcTimeoutCount) // If anchor outputs is used, we use the anchor output to bump the fees if necessary. - val publishedAnchorTx = s2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx].tx - // if s has a main output in the commit tx (when it has a non-dust balance), it should be claimed - val publishedMainTx_opt = remoteCommitPublished.localOutput_opt.map(_ => s2blockchain.expectFinalTxPublished("remote-main-delayed").tx) - publishedMainTx_opt.foreach(tx => Transaction.correctlySpends(tx, rCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + // When v3 transactions are used, we instead use our main output to bump the fees. + val (publishedMainTx_opt, publishedAnchorTx_opt) = closingData.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => + val publishedAnchorTx = s2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx].tx + val publishedMainTx_opt = remoteCommitPublished.localOutput_opt.map(_ => s2blockchain.expectFinalTxPublished("remote-main").tx) + publishedMainTx_opt.foreach(tx => Transaction.correctlySpends(tx, rCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + (publishedMainTx_opt, Some(publishedAnchorTx)) + case ZeroFeeCommitmentFormat => + val publishedMainTx = s2blockchain.expectReplaceableTxPublished[ClaimRemoteMainOutputTx].tx + (Some(publishedMainTx), None) + } // all htlcs success/timeout should be claimed val publishedClaimHtlcTxs = (0 until htlcSuccessCount + htlcTimeoutCount).map { _ => val claimHtlcTx = s2blockchain.expectMsgType[PublishReplaceableTx] @@ -744,18 +762,18 @@ trait ChannelStateTestsBase extends Assertions with Eventually { // we watch outputs of the commitment tx that we want to claim remoteCommitPublished.localOutput_opt.foreach(outpoint => s2blockchain.expectWatchOutputSpent(outpoint)) - remoteCommitPublished.anchorOutput_opt.foreach(outpoint => s2blockchain.expectWatchOutputSpent(outpoint)) + publishedAnchorTx_opt.map(_.txIn.head.outPoint).foreach(outpoint => s2blockchain.expectWatchOutputSpent(outpoint)) s2blockchain.expectWatchOutputsSpent(remoteCommitPublished.htlcOutputs.toSeq) s2blockchain.expectNoMessage(100 millis) // once our closing transactions are published, we watch for their confirmation - (publishedMainTx_opt ++ Seq(publishedAnchorTx) ++ publishedHtlcSuccessTxs ++ publishedHtlcTimeoutTxs).foreach(tx => { + (publishedMainTx_opt ++ publishedAnchorTx_opt.toSeq ++ publishedHtlcSuccessTxs ++ publishedHtlcTimeoutTxs).foreach(tx => { s ! WatchOutputSpentTriggered(tx.txOut.headOption.map(_.amount).getOrElse(330 sat), tx) s2blockchain.expectWatchTxConfirmed(tx.txid) }) // s is now in CLOSING state with txs pending for confirmation before going in CLOSED state - val publishedTxs = PublishedForceCloseTxs(publishedMainTx_opt, publishedAnchorTx, publishedHtlcSuccessTxs, publishedHtlcTimeoutTxs) + val publishedTxs = PublishedForceCloseTxs(publishedMainTx_opt, publishedAnchorTx_opt, publishedHtlcSuccessTxs, publishedHtlcTimeoutTxs) (remoteCommitPublished, publishedTxs) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala index 07b371dcca..684a6a7a09 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala @@ -135,11 +135,20 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS test("recv AcceptChannel (invalid max accepted htlcs)") { f => import f._ val accept = bob2alice.expectMsgType[AcceptChannel] - // spec says max = 483 - val invalidMaxAcceptedHtlcs = 484 - alice ! accept.copy(maxAcceptedHtlcs = invalidMaxAcceptedHtlcs) + alice ! accept.copy(maxAcceptedHtlcs = 484) val error = alice2bob.expectMsgType[Error] - assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, invalidMaxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS).getMessage)) + assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, maxAcceptedHtlcs = 484, max = 483).getMessage)) + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] + } + + test("recv AcceptChannel (invalid max accepted htlcs, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val accept = bob2alice.expectMsgType[AcceptChannel] + alice ! accept.copy(maxAcceptedHtlcs = 115) + val error = alice2bob.expectMsgType[Error] + assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, maxAcceptedHtlcs = 115, max = 114).getMessage)) listener.expectMsgType[ChannelAborted] awaitCond(alice.stateName == CLOSED) aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala index 0ab2f06274..37450619dc 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptDualFundedChannelStateSpec.scala @@ -181,10 +181,20 @@ class WaitForAcceptDualFundedChannelStateSpec extends TestKitBaseClass with Fixt test("recv AcceptDualFundedChannel (invalid max accepted htlcs)", Tag(ChannelStateTestsTags.DualFunding)) { f => import f._ val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] - val invalidMaxAcceptedHtlcs = Channel.MAX_ACCEPTED_HTLCS + 1 - alice ! accept.copy(maxAcceptedHtlcs = invalidMaxAcceptedHtlcs) + alice ! accept.copy(maxAcceptedHtlcs = 484) val error = alice2bob.expectMsgType[Error] - assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, invalidMaxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS).getMessage)) + assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, maxAcceptedHtlcs = 484, max = 483).getMessage)) + listener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] + } + + test("recv AcceptDualFundedChannel (invalid max accepted htlcs, zero-fee commitments)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] + alice ! accept.copy(maxAcceptedHtlcs = 115) + val error = alice2bob.expectMsgType[Error] + assert(error == Error(accept.temporaryChannelId, InvalidMaxAcceptedHtlcs(accept.temporaryChannelId, maxAcceptedHtlcs = 115, max = 114).getMessage)) listener.expectMsgType[ChannelAborted] awaitCond(alice.stateName == CLOSED) aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala index 3248890918..10c6184404 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala @@ -19,11 +19,11 @@ package fr.acinq.eclair.channel.states.a import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{Block, Btc, ByteVector32, SatoshiLong, TxId} import fr.acinq.eclair.TestConstants.Bob -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} -import fr.acinq.eclair.transactions.Transactions.{ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{ZeroFeeCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{AcceptChannel, ChannelTlv, Error, OpenChannel, TlvStream} import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -97,6 +97,28 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui awaitCond(bob.stateName == CLOSED) } + test("recv OpenChannel (zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val open = alice2bob.expectMsgType[OpenChannel] + assert(open.channelType_opt.contains(ChannelTypes.ZeroFeeCommitments())) + assert(open.feeratePerKw == FeeratePerKw(0 sat)) + alice2bob.forward(bob) + awaitCond(bob.stateName == WAIT_FOR_FUNDING_CREATED) + assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == ZeroFeeCommitmentFormat) + } + + test("recv OpenChannel (zero-fee commitments, non-zero feerate)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val open = alice2bob.expectMsgType[OpenChannel] + assert(open.channelType_opt.contains(ChannelTypes.ZeroFeeCommitments())) + assert(open.feeratePerKw == FeeratePerKw(0 sat)) + alice2bob.forward(bob, open.copy(feeratePerKw = FeeratePerByte(1 sat).perKw)) + val error = bob2alice.expectMsgType[Error] + assert(error == Error(open.temporaryChannelId, FeerateTooDifferent(open.temporaryChannelId, FeeratePerKw(0 sat), FeeratePerByte(1 sat).perKw).getMessage)) + listener.expectMsgType[ChannelAborted] + awaitCond(bob.stateName == CLOSED) + } + test("recv OpenChannel (invalid chain)") { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] @@ -147,10 +169,19 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui test("recv OpenChannel (invalid max accepted htlcs)") { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] - val invalidMaxAcceptedHtlcs = Channel.MAX_ACCEPTED_HTLCS + 1 - bob ! open.copy(maxAcceptedHtlcs = invalidMaxAcceptedHtlcs) + bob ! open.copy(maxAcceptedHtlcs = 484) + val error = bob2alice.expectMsgType[Error] + assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, maxAcceptedHtlcs = 484, max = 483).getMessage)) + listener.expectMsgType[ChannelAborted] + awaitCond(bob.stateName == CLOSED) + } + + test("recv OpenChannel (invalid max accepted htlcs, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val open = alice2bob.expectMsgType[OpenChannel] + alice2bob.forward(bob, open.copy(maxAcceptedHtlcs = 115)) val error = bob2alice.expectMsgType[Error] - assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, invalidMaxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS).getMessage)) + assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, maxAcceptedHtlcs = 115, max = 114).getMessage)) listener.expectMsgType[ChannelAborted] awaitCond(bob.stateName == CLOSED) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala index 07b7ae96fb..eeb3ac831a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenDualFundedChannelStateSpec.scala @@ -19,6 +19,7 @@ package fr.acinq.eclair.channel.states.a import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, ByteVector32, SatoshiLong} import fr.acinq.eclair.TestConstants.Bob +import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} @@ -91,6 +92,31 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) } + test("recv OpenDualFundedChannel (zero-fee commitments)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + + val open = alice2bob.expectMsgType[OpenDualFundedChannel] + assert(open.channelType_opt.contains(ChannelTypes.ZeroFeeCommitments())) + assert(open.fundingFeerate == TestConstants.feeratePerKw) + assert(open.commitmentFeerate == FeeratePerKw(0 sat)) + alice2bob.forward(bob) + val accept = bob2alice.expectMsgType[AcceptDualFundedChannel] + assert(accept.channelType_opt.contains(ChannelTypes.ZeroFeeCommitments())) + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_CREATED) + } + + test("recv OpenDualFundedChannel (zero-fee commitments, non-zero feerate)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + + val open = alice2bob.expectMsgType[OpenDualFundedChannel] + assert(open.channelType_opt.contains(ChannelTypes.ZeroFeeCommitments())) + alice2bob.forward(bob, open.copy(commitmentFeerate = FeeratePerByte(1 sat).perKw)) + val error = bob2alice.expectMsgType[Error] + assert(error == Error(open.temporaryChannelId, FeerateTooDifferent(open.temporaryChannelId, FeeratePerKw(0 sat), FeeratePerByte(1 sat).perKw).getMessage)) + bobListener.expectMsgType[ChannelAborted] + awaitCond(bob.stateName == CLOSED) + } + test("recv OpenDualFundedChannel (with liquidity ads)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds)) { f => import f._ @@ -172,10 +198,19 @@ class WaitForOpenDualFundedChannelStateSpec extends TestKitBaseClass with Fixtur test("recv OpenDualFundedChannel (invalid max accepted htlcs)", Tag(ChannelStateTestsTags.DualFunding)) { f => import f._ val open = alice2bob.expectMsgType[OpenDualFundedChannel] - val invalidMaxAcceptedHtlcs = Channel.MAX_ACCEPTED_HTLCS + 1 - bob ! open.copy(maxAcceptedHtlcs = invalidMaxAcceptedHtlcs) + bob ! open.copy(maxAcceptedHtlcs = 484) + val error = bob2alice.expectMsgType[Error] + assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, maxAcceptedHtlcs = 484, max = 483).getMessage)) + bobListener.expectMsgType[ChannelAborted] + awaitCond(bob.stateName == CLOSED) + } + + test("recv OpenDualFundedChannel (invalid max accepted htlcs, zero-fee commitments)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val open = alice2bob.expectMsgType[OpenDualFundedChannel] + bob ! open.copy(maxAcceptedHtlcs = 115) val error = bob2alice.expectMsgType[Error] - assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, invalidMaxAcceptedHtlcs, Channel.MAX_ACCEPTED_HTLCS).getMessage)) + assert(error == Error(open.temporaryChannelId, InvalidMaxAcceptedHtlcs(open.temporaryChannelId, maxAcceptedHtlcs = 115, max = 114).getMessage)) bobListener.expectMsgType[ChannelAborted] awaitCond(bob.stateName == CLOSED) } 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 7566087c06..1bb2eceb2d 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 @@ -774,7 +774,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice ! WatchFundingSpentTriggered(bobCommitTx) aliceListener.expectMsgType[TransactionPublished] alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMain.tx, Seq(bobCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice2blockchain.expectWatchOutputSpent(claimMain.input) @@ -810,7 +810,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice ! WatchFundingSpentTriggered(bobCommitTx1) assert(aliceListener.expectMsgType[TransactionPublished].tx.txid == bobCommitTx1.txid) alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMain.tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) alice2blockchain.expectWatchOutputSpent(claimMain.input) @@ -833,7 +833,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(alice2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx.txid) alice2 ! WatchFundingSpentTriggered(bobCommitTx) alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainAlice = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainAlice = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainAlice.tx, Seq(bobCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice2blockchain.expectWatchOutputSpent(claimMainAlice.input) @@ -844,7 +844,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(bob2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx.txid) bob2 ! WatchFundingSpentTriggered(aliceCommitTx) bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainBob.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) bob2blockchain.expectWatchTxConfirmed(aliceCommitTx.txid) bob2blockchain.expectWatchOutputSpent(claimMainBob.input) @@ -872,7 +872,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice2blockchain.expectMsg(UnwatchTxConfirmed(fundingTx2.txId)) alice2 ! WatchFundingSpentTriggered(bobCommitTx1) alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainAlice = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainAlice = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainAlice.tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) alice2blockchain.expectWatchOutputSpent(claimMainAlice.input) @@ -885,7 +885,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture bob2blockchain.expectMsg(UnwatchTxConfirmed(fundingTx2.txId)) bob2 ! WatchFundingSpentTriggered(aliceCommitTx1) bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainBob.tx, Seq(aliceCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) bob2blockchain.expectWatchTxConfirmed(aliceCommitTx1.txid) bob2blockchain.expectWatchOutputSpent(claimMainBob.input) @@ -1331,7 +1331,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val bobCommitTx = bob.signCommitTx() alice ! WatchFundingSpentTriggered(bobCommitTx) val anchorTxRemote = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainRemote = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainRemote = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainRemote.tx, Seq(bobCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice2blockchain.expectWatchOutputSpent(claimMainRemote.input) @@ -1379,7 +1379,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture // Bob publishes his commit tx, Alice reacts by spending her remote main output. alice ! WatchFundingSpentTriggered(bobCommitTx1) val anchorRemote = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainRemote = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainRemote = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainRemote.tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) alice2blockchain.expectWatchOutputsSpent(Seq(anchorRemote.input.outPoint, claimMainRemote.input)) 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 19f9ff9564..28c0d9b3c9 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 @@ -3369,7 +3369,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Bob detects Alice's commit tx. bob ! WatchFundingSpentTriggered(commitTx2) val anchorBob = bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main") val bobHtlcTimeout = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) bobHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) bob2blockchain.expectWatchTxConfirmed(commitTx2.txid) @@ -3476,7 +3476,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // We're back to the normal handling of remote commit. val remoteAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main") val htlcTimeout = htlcs.aliceToBob.map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) htlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) // NB: this one fires immediately, tx is already confirmed. @@ -3559,7 +3559,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Bob's revoked commit tx wins. alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) // Alice reacts by punishing bob. - val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenalty = (htlcs.aliceToBob ++ htlcs.bobToAlice).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) htlcPenalty.foreach(penalty => Transaction.correctlySpends(penalty.tx, Seq(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) @@ -3661,7 +3661,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobCommitTx1) // we're back to the normal handling of remote commit val remoteAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main") val claimHtlcTimeout = htlcs.aliceToBob.map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) claimHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) awaitCond(aliceWallet.asInstanceOf[SingleKeyOnChainWallet].abandoned.contains(fundingTx2.txid)) @@ -3759,7 +3759,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // bob's revoked tx wins alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) // alice reacts by punishing bob - val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val remoteMain = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenalty = (htlcs.aliceToBob ++ htlcs.bobToAlice).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) alice2blockchain.expectWatchTxConfirmed(bobRevokedCommitTx.txid) @@ -3820,7 +3820,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Bob detects Alice's commit tx. bob ! WatchFundingSpentTriggered(commitTx2) val bobAnchorTx = bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(claimMainBob.tx, Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val bobHtlcTimeout = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) bobHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) @@ -3911,7 +3911,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice's commit tx confirms. bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) val anchorTx = bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val bobHtlcTimeout = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) bobHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) @@ -3954,7 +3954,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice's commit tx confirms. bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) - val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -3991,7 +3991,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(alice, bob, alice2bob, bob2alice) bob ! WatchFundingSpentTriggered(aliceCommitTx) // Bob reacts by publishing penalty transactions. - val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -4060,7 +4060,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice's revoked commit tx confirms. bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) - val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index afe94bf6c5..c1015dfbde 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1096,6 +1096,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRecvCommitSigMultipleHtlcZeroFees(f) } + test("recv CommitSig (multiple htlcs in both directions, zero fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testRecvCommitSigMultipleHtlcZeroFees(f) + } + test("recv CommitSig (multiple htlcs in both directions) (without fundingTxId tlv)") { f => import f._ @@ -1636,6 +1640,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRevokeAndAckHtlc(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv RevokeAndAck (one htlc sent, zero_fee_commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testRevokeAndAckHtlc(f, ZeroFeeCommitmentFormat) + } + test("recv RevocationTimeout") { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -1679,6 +1687,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testReceiveCmdFulfillHtlc(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv CMD_FULFILL_HTLC (zero_fee_commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testReceiveCmdFulfillHtlc(f, ZeroFeeCommitmentFormat) + } + test("recv CMD_FULFILL_HTLC (unknown htlc id)") { f => import f._ val sender = TestProbe() @@ -1851,6 +1863,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testCmdFailHtlc(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv CMD_FAIL_HTLC (zero_fee_commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testCmdFailHtlc(f, ZeroFeeCommitmentFormat) + } + test("recv CMD_FAIL_HTLC (with delay)") { f => import f._ val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -2152,6 +2168,15 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectWatchTxConfirmed(tx.txid) } + test("recv UpdateFee (zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + val tx = bob.signCommitTx() + val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] + bob ! UpdateFee(ByteVector32.Zeroes, TestConstants.anchorOutputsFeeratePerKw) + bob2alice.expectMsgType[Error] + awaitCond(bob.stateName == CLOSING) + } + test("recv UpdateFee (sender can't afford it)", Tag(ChannelStateTestsTags.HighFeerateMismatchTolerance)) { f => import f._ val tx = bob.signCommitTx() @@ -2734,6 +2759,15 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2bob.expectNoMessage(100 millis) } + test("recv CurrentFeerate (when funder, doesn't trigger and UpdateFee with zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + assert(alice.commitments.latest.localCommit.spec.commitTxFeerate == FeeratePerKw(0 sat)) + val event = CurrentFeerates.BitcoinCore(FeeratesPerKw.single(FeeratePerKw(5000 sat)).copy(minimum = FeeratePerKw(250 sat))) + alice.setBitcoinCoreFeerates(event.feeratesPerKw) + alice ! event + alice2bob.expectNoMessage(100 millis) + } + test("recv CurrentFeerate (when fundee, commit-fee/network-fee are close)") { f => import f._ val event = CurrentFeerates.BitcoinCore(FeeratesPerKw.single(FeeratePerKw(11000 sat))) @@ -2797,7 +2831,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs val claimAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main").tx // in addition to her main output, alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) @@ -2884,7 +2918,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs val claimAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main").tx // in addition to her main output, alice can only claim 2 out of 3 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) @@ -2963,7 +2997,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val rvk = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head assert(rvk.htlcOutputs.size == 4) - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main").tx val mainPenaltyTx = alice2blockchain.expectFinalTxPublished("main-penalty").tx val htlcPenaltyTxs = (0 until 4).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty").tx) // let's make sure that htlc-penalty txs each spend a different output @@ -3019,7 +3053,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1) - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main").tx val mainPenaltyTx = alice2blockchain.expectFinalTxPublished("main-penalty").tx val htlcPenaltyTxs = (0 until 2).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty").tx) // let's make sure that htlc-penalty txs each spend a different output diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index 6628813716..ff72359871 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -684,7 +684,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit // in response to that, alice publishes her claim txs val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val claimMain = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val claimMain = alice2blockchain.expectFinalTxPublished("remote-main") // in addition to her main output, alice can only claim 2 out of 3 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) val htlcAmountClaimed = claimHtlcTxs.map(claimHtlcTx => { @@ -730,7 +730,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit // in response to that, alice publishes her claim txs val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] val claimTxs = Seq( - alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx, + alice2blockchain.expectFinalTxPublished("remote-main").tx, // there is only one htlc to claim in the commitment bob published alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx].sign() ) @@ -766,7 +766,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit awaitCond(alice.stateName == CLOSING) assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1) - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main").tx val mainPenaltyTx = alice2blockchain.expectFinalTxPublished("main-penalty").tx val htlc1PenaltyTx = alice2blockchain.expectFinalTxPublished("htlc-penalty").tx val htlc2PenaltyTx = alice2blockchain.expectFinalTxPublished("htlc-penalty").tx @@ -804,7 +804,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit awaitCond(alice.stateName == CLOSING) assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1) - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed").tx + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main").tx val mainPenaltyTx = alice2blockchain.expectFinalTxPublished("main-penalty").tx val htlcPenaltyTx = alice2blockchain.expectFinalTxPublished("htlc-penalty").tx Seq(mainTx, mainPenaltyTx, htlcPenaltyTx).foreach(tx => Transaction.correctlySpends(tx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index 3216b151d3..caed474641 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -395,8 +395,14 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob claims the htlc output from Alice's commit tx using its preimage. bob ! WatchFundingSpentTriggered(lcp.commitTx) - bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - bob2blockchain.expectFinalTxPublished("remote-main-delayed") + bob.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => + bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] + bob2blockchain.expectFinalTxPublished("remote-main") + case ZeroFeeCommitmentFormat => + // We don't need a dedicated anchor transaction, we'll use our main output instead. + bob2blockchain.expectReplaceableTxPublished[ClaimRemoteMainOutputTx] + } val claimHtlcSuccessTx1 = bob2blockchain.expectReplaceableTxPublished[ClaimHtlcSuccessTx].sign() val claimHtlcSuccessTx2 = bob2blockchain.expectReplaceableTxPublished[ClaimHtlcSuccessTx].sign() assert(Seq(claimHtlcSuccessTx1, claimHtlcSuccessTx2).flatMap(_.txIn.map(_.outPoint)).toSet == lcp.htlcOutputs) @@ -425,6 +431,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromClaimHtlcSuccess(f) } + test("recv WatchOutputSpentTriggered (extract preimage from Claim-HTLC-success tx, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + extractPreimageFromClaimHtlcSuccess(f) + } + private def extractPreimageFromHtlcSuccess(f: FixtureParam): Unit = { import f._ @@ -465,6 +475,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromHtlcSuccess(f) } + test("recv WatchOutputSpentTriggered (extract preimage from HTLC-success tx, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + extractPreimageFromHtlcSuccess(f) + } + private def extractPreimageFromRemovedHtlc(f: FixtureParam): Unit = { import f._ @@ -500,13 +514,20 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice prepares Claim-HTLC-timeout transactions for each HTLC. alice ! WatchFundingSpentTriggered(rcp.commitTx) - val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val (mainTx_opt, anchorTx_opt) = alice.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => + val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx].tx + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main").tx + (Some(mainTx), Some(anchorTx)) + case ZeroFeeCommitmentFormat => + val mainTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteMainOutputTx].tx + (Some(mainTx), None) + } val claimHtlcTimeoutTxs = Seq(htlc1, htlc2, htlc3).map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) assert(claimHtlcTimeoutTxs.map(_.htlcId).toSet == Set(htlc1, htlc2, htlc3).map(_.id)) alice2blockchain.expectWatchTxConfirmed(rcp.commitTx.txid) - alice2blockchain.expectWatchOutputSpent(mainTx.input) - alice2blockchain.expectWatchOutputSpent(anchorTx.input.outPoint) + mainTx_opt.foreach(tx => alice2blockchain.expectWatchOutputSpent(tx.txIn.head.outPoint)) + anchorTx_opt.foreach(tx => alice2blockchain.expectWatchOutputSpent(tx.txIn.head.outPoint)) alice2blockchain.expectWatchOutputsSpent(claimHtlcTimeoutTxs.map(_.input.outPoint)) alice2blockchain.expectNoMessage(100 millis) @@ -546,6 +567,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromRemovedHtlc(f) } + test("recv WatchOutputSpentTriggered (extract preimage for removed HTLC, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + extractPreimageFromRemovedHtlc(f) + } + private def extractPreimageFromNextHtlcs(f: FixtureParam): Unit = { import f._ @@ -587,7 +612,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice prepares Claim-HTLC-timeout transactions for each HTLC. alice ! WatchFundingSpentTriggered(rcp.commitTx) val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val claimHtlcTimeoutTxs = Seq(htlc1, htlc2, htlc3).map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) assert(claimHtlcTimeoutTxs.map(_.htlcId).toSet == Set(htlc1, htlc2, htlc3).map(_.id)) alice2blockchain.expectWatchTxConfirmed(rcp.commitTx.txid) @@ -714,6 +739,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testLocalCommitTxConfirmed(f, PhoenixSimpleTaprootChannelCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (local commit, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testLocalCommitTxConfirmed(f, ZeroFeeCommitmentFormat) + } + test("recv WatchTxConfirmedTriggered (local commit with multiple htlcs for the same payment)") { f => import f._ @@ -1014,7 +1043,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx].input.outPoint == htlcTimeoutTx.txIn.head.outPoint) alice2blockchain.expectWatchTxConfirmed(closingState.commitTx.txid) closingTxs.mainTx_opt.foreach(tx => alice2blockchain.expectWatchOutputSpent(tx.txIn.head.outPoint)) - alice2blockchain.expectWatchOutputSpent(closingTxs.anchorTx.txIn.head.outPoint) + closingTxs.anchorTx_opt.foreach(tx => alice2blockchain.expectWatchOutputSpent(tx.txIn.head.outPoint)) alice2blockchain.expectWatchOutputSpent(htlcTimeoutTx.txIn.head.outPoint) // the htlc transaction confirms, so we publish a 3rd-stage transaction @@ -1061,7 +1090,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // The commit tx confirms. alice ! WatchTxConfirmedTriggered(BlockHeight(42), 0, closingState1.commitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(42), 1, closingTxs.anchorTx) + closingTxs.anchorTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(42), 1, tx)) alice2blockchain.expectNoMessage(100 millis) // Alice receives the preimage for the incoming HTLC. @@ -1152,7 +1181,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // The commit transaction and main transactions confirm. alice ! WatchTxConfirmedTriggered(BlockHeight(750_000), 3, closingStateAlice.commitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(750_000), 5, closingTxsAlice.anchorTx) + alice ! WatchTxConfirmedTriggered(BlockHeight(750_000), 5, closingTxsAlice.anchorTx_opt.get) alice ! WatchTxConfirmedTriggered(BlockHeight(750_001), 1, closingTxsAlice.mainTx_opt.get) alice ! WatchTxConfirmedTriggered(BlockHeight(750_001), 2, closingTxsBob.mainTx_opt.get) alice2blockchain.expectNoMessage(100 millis) @@ -1359,6 +1388,29 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit } + test("recv WatchFundingSpentTriggered (remote commit, zero-fee commitments with small balance)", Tag(ChannelStateTestsTags.ZeroFeeCommitments), Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f => + import f._ + + // Alice sends almost all of her balance to Bob. + val aliceInitialBalance = alice.commitments.availableBalanceForSend + addHtlc(aliceInitialBalance, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + + // The feerate is quite high for fast inclusion in the blockchain. + alice.nodeParams.setBitcoinCoreFeerates(FeeratesPerKw.single(FeeratePerKw(20_000 sat)).copy(minimum = FeeratePerKw(253 sat), slow = FeeratePerKw(500 sat))) + // Bob force-closes while the HTLC is pending. + val bobCommitTx = bob.signCommitTx() + alice ! WatchFundingSpentTriggered(bobCommitTx) + // Alice claims the remote anchor, because her main output is too small to be used for CPFP. + // She has a pending outgoing HTLC, which means some funds are at risk. + val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") + val htlcTimeoutTx = alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx] + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + alice2blockchain.expectWatchOutputsSpent(Seq(anchorTx.input.outPoint, mainTx.input, htlcTimeoutTx.input.outPoint)) + alice2blockchain.expectNoMessage(100 millis) + } + test("recv CMD_BUMP_FORCE_CLOSE_FEE (remote commit)") { f => import f._ @@ -1433,13 +1485,15 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob publishes the latest commit tx. val bobCommitTx = bob.signCommitTx() - assert(bobCommitTx.txOut.length == 7) // two main outputs + two anchors + 3 HTLCs + commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommitTx.txOut.length == 7) // two main outputs + two anchors + 3 HTLCs + case ZeroFeeCommitmentFormat => assert(bobCommitTx.txOut.length == 6) // two main outputs + one anchors + 3 HTLCs + } val (closingState, closingTxs) = remoteClose(bobCommitTx, alice, alice2blockchain, htlcTimeoutCount = 3) assert(closingState.htlcOutputs.size == 3) assert(closingTxs.htlcTimeoutTxs.length == 3) alice ! WatchTxConfirmedTriggered(BlockHeight(42), 0, bobCommitTx) - // for static_remote_key channels there is no claimMainOutputTx (bob's commit tx directly sends to our wallet) closingTxs.mainTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(45), 0, tx)) alice2relayer.expectNoMessage(100 millis) alice ! WatchTxConfirmedTriggered(BlockHeight(201), 0, closingTxs.htlcTimeoutTxs(0)) @@ -1467,6 +1521,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRemoteCommitTxWithHtlcsConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (remote commit with multiple htlcs for the same payment, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testRemoteCommitTxWithHtlcsConfirmed(f, ZeroFeeCommitmentFormat) + } + test("recv WatchTxConfirmedTriggered (remote commit) followed by htlc settlement") { f => import f._ // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. @@ -1498,7 +1556,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob's commitment confirms: the third htlc was not included in the commit tx published on-chain, so we can consider it failed. alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(0), 1, closingTxs.anchorTx) + closingTxs.anchorTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 1, tx)) assert(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc == htlc3) // Alice's main transaction confirms. closingTxs.mainTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, tx)) @@ -1546,7 +1604,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Our main transaction should have a lower feerate. // HTLC transactions are unchanged: the feerate will be based on their expiry. closingTxs.mainTx_opt.foreach(tx => { - val tx2 = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val tx2 = alice2blockchain.expectFinalTxPublished("remote-main") assert(tx2.tx.txIn.head.outPoint == tx.txIn.head.outPoint) assert(tx2.tx.txOut.head.amount > tx.txOut.head.amount) }) @@ -1556,6 +1614,28 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectWatchOutputsSpent(Seq(closingTxs.mainTx_opt.get.txIn.head.outPoint, htlcTimeout.input.outPoint)) } + test("recv INPUT_RESTORED (remote commit, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + + val bobCommitTx = bob.signCommitTx() + val (_, closingTxs) = remoteClose(bobCommitTx, alice, alice2blockchain) + // Alice uses her main output to bump the commit fees instead of using a dedicated anchor transaction. + assert(closingTxs.anchorTx_opt.isEmpty) + // The remote commitment confirms independently (e.g. Bob spent the anchor output with his wallet inputs). + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) + + // We simulate a node restart. + val beforeRestart = alice.stateData.asInstanceOf[DATA_CLOSING] + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(beforeRestart) + alice2blockchain.expectMsgType[SetChannelId] + // We don't need to spend the anchor output anymore with our main transaction: it is a simple standalone transaction. + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") + assert(mainTx.input == closingTxs.mainTx_opt.get.txIn.head.outPoint) + alice2blockchain.expectWatchOutputSpent(mainTx.input) + alice2blockchain.expectNoMessage(100 millis) + } + private def testNextRemoteCommitTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): (Transaction, PublishedForceCloseTxs, Set[UpdateAddHtlc]) = { import f._ @@ -1662,7 +1742,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob's commitment and Alice's main transaction confirm. alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, closingTxs.anchorTx) + closingTxs.anchorTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, tx)) closingTxs.mainTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, tx)) // Alice receives a failure for the second HTLC from downstream; she can stop watching the corresponding HTLC output. @@ -1706,7 +1786,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchFundingSpent] // then we should re-publish unconfirmed transactions val anchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] - closingTxs.mainTx_opt.foreach(_ => alice2blockchain.expectFinalTxPublished("remote-main-delayed")) + closingTxs.mainTx_opt.foreach(_ => alice2blockchain.expectFinalTxPublished("remote-main")) val htlcTimeoutTxs = closingTxs.htlcTxs.map(_ => alice2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) assert(htlcTimeoutTxs.map(_.input.outPoint).toSet == closingTxs.htlcTxs.map(_.txIn.head.outPoint).toSet) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) @@ -1761,7 +1841,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) // alice is able to claim its main output - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice2blockchain.expectWatchOutputSpent(mainTx.input) @@ -1778,7 +1858,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) // alice is able to claim its main output - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") Transaction.correctlySpends(mainTx.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice2blockchain.expectWatchOutputSpent(mainTx.input) @@ -1802,7 +1882,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // bob republishes his main transaction - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mainTx.tx) @@ -1818,7 +1898,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob's first commit tx doesn't contain any htlc val bobCommit1 = RevokedCommit(bob.signCommitTx(), Nil) - assert(bobCommit1.commitTx.txOut.size == 4) // 2 main outputs + 2 anchors + bob.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit1.commitTx.txOut.size == 4) // 2 main outputs + 2 anchors + case ZeroFeeCommitmentFormat => assert(bobCommit1.commitTx.txOut.size == 3) // 2 main outputs + 1 anchor + } // Bob's second commit tx contains 1 incoming htlc and 1 outgoing htlc val (bobCommit2, htlcAlice1, htlcBob1) = { @@ -1831,7 +1914,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit2.commitTx.txOut.size) - assert(bobCommit2.commitTx.txOut.size == 6) + bob.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit2.commitTx.txOut.size == 6) + case ZeroFeeCommitmentFormat => assert(bobCommit2.commitTx.txOut.size == 5) + } // Bob's third commit tx contains 2 incoming htlcs and 2 outgoing htlcs val (bobCommit3, htlcAlice2, htlcBob2) = { @@ -1844,7 +1930,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit3.commitTx.txOut.size) - assert(bobCommit3.commitTx.txOut.size == 8) + bob.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit3.commitTx.txOut.size == 8) + case ZeroFeeCommitmentFormat => assert(bobCommit3.commitTx.txOut.size == 7) + } // Bob's fourth commit tx doesn't contain any htlc val bobCommit4 = { @@ -1855,7 +1944,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit4.commitTx.txOut.size) - assert(bobCommit4.commitTx.txOut.size == 4) + bob.commitments.latest.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit4.commitTx.txOut.size == 4) + case ZeroFeeCommitmentFormat => assert(bobCommit4.commitTx.txOut.size == 3) + } RevokedCloseFixture(Seq(bobCommit1, bobCommit2, bobCommit3, bobCommit4), Seq(htlcAlice1, htlcAlice2), Seq(htlcBob1, htlcBob2)) } @@ -1882,7 +1974,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(rvk.htlcDelayedOutputs.isEmpty) // alice publishes the penalty txs - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenaltyTx = alice2blockchain.expectFinalTxPublished("main-penalty") Transaction.correctlySpends(mainPenaltyTx.tx, bobRevokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val htlcPenaltyTxs = (0 until 2).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) @@ -1891,7 +1983,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice spends all outpoints of the revoked tx, except her main output when it goes directly to our wallet val spentOutpoints = Seq(mainTx.input, mainPenaltyTx.input) ++ htlcPenaltyTxs.map(_.input) - assert(spentOutpoints.size == bobRevokedTx.txOut.size - 2) // we don't claim the anchors + commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(spentOutpoints.size == bobRevokedTx.txOut.size - 2) // we don't claim the anchors + case ZeroFeeCommitmentFormat => assert(spentOutpoints.size == bobRevokedTx.txOut.size - 1) // we don't claim the anchor + } // alice watches on-chain transactions alice2blockchain.expectWatchTxConfirmed(bobRevokedTx.txid) @@ -1907,7 +2002,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val (bobRevokedTx, closingTxs) = setupFundingSpentRevokedTx(f, commitmentFormat) val txPublished = txListener.expectMsgType[TransactionPublished] assert(txPublished.tx == bobRevokedTx) - assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the revoked commit // once all txs are confirmed, alice can move to the closed state alice ! WatchTxConfirmedTriggered(BlockHeight(100), 3, bobRevokedTx) @@ -1936,6 +2030,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testFundingSpentRevokedTx(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv WatchFundingSpentTriggered (one revoked tx, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testFundingSpentRevokedTx(f, ZeroFeeCommitmentFormat) + } + test("recv WatchFundingSpentTriggered (multiple revoked tx)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ val revokedCloseFixture = prepareRevokedClose(f) @@ -1948,7 +2046,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.last.commitTx == revokedTx) // alice publishes penalty txs - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenaltyTxs = (1 to htlcCount).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) (mainTx.tx +: mainPenalty.tx +: htlcPenaltyTxs.map(_.tx)).foreach(tx => Transaction.correctlySpends(tx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) @@ -1992,7 +2090,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // the commit tx hasn't been confirmed yet, so we watch the funding output first alice2blockchain.expectMsgType[WatchFundingSpent] // then we should re-publish unconfirmed transactions - alice2blockchain.expectFinalTxPublished("remote-main-delayed") + alice2blockchain.expectFinalTxPublished("remote-main") assert(alice2blockchain.expectFinalTxPublished("main-penalty").input == closingTxs.mainPenaltyTx.txIn.head.outPoint) val htlcPenaltyTxs = closingTxs.htlcPenaltyTxs.map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) assert(htlcPenaltyTxs.map(_.input).toSet == closingTxs.htlcPenaltyTxs.map(_.txIn.head.outPoint).toSet) @@ -2033,7 +2131,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(rvk.htlcDelayedOutputs.isEmpty) // alice publishes the penalty txs and watches outputs - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenalty = (1 to 4).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) alice2blockchain.expectWatchTxConfirmed(rvk.commitTx.txid) @@ -2121,7 +2219,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(rvk.htlcDelayedOutputs.isEmpty) // alice publishes the penalty txs and watches outputs - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenalty = (1 to 4).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) alice2blockchain.expectWatchTxConfirmed(rvk.commitTx.txid) @@ -2180,6 +2278,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRevokedAggregatedHtlcTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (revoked aggregated htlc tx, zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + testRevokedAggregatedHtlcTxConfirmed(f, ZeroFeeCommitmentFormat) + } + def testInputRestoredRevokedHtlcTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ @@ -2193,7 +2295,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // Alice publishes the penalty txs and watches outputs. - val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenalty = (1 to 4).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) alice2blockchain.expectWatchTxConfirmed(commitTx.txid) @@ -2257,7 +2359,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // We re-publish closing transactions with a higher feerate. - val mainTx2 = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx2 = alice2blockchain.expectFinalTxPublished("remote-main") assert(mainTx2.input == mainTx.input) assert(mainTx2.tx.txOut.head.amount < mainTx.tx.txOut.head.amount) Transaction.correctlySpends(mainTx2.tx, Seq(commitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala index 66c476229d..bce8950bc7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala @@ -34,6 +34,7 @@ import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment import fr.acinq.eclair.payment.receive.{ForwardHandler, PaymentHandler} import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToNode import fr.acinq.eclair.router.Router +import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, SimpleTaprootChannelCommitmentFormat, ZeroFeeCommitmentFormat} import fr.acinq.eclair.transactions.{OutgoingHtlc, Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{MilliSatoshi, MilliSatoshiLong, randomBytes32} @@ -394,7 +395,10 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { val commitmentKeysF = commitmentsF.latest.localKeys(channelKeysF) val revokedCommitTx = commitmentsF.latest.fullySignedLocalCommitTx(channelKeysF) // in this commitment, both parties should have a main output, there are four pending htlcs and anchor outputs if applicable - assert(revokedCommitTx.txOut.size == 8) + channelType.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(revokedCommitTx.txOut.size == 8) + case ZeroFeeCommitmentFormat => assert(revokedCommitTx.txOut.size == 7) + } val outgoingHtlcExpiry = commitmentsF.latest.localCommit.spec.htlcs.collect { case OutgoingHtlc(add) => add.cltvExpiry }.max val htlcTxsF = commitmentsF.latest.htlcTxs(channelKeysF) val htlcTimeoutTxs = htlcTxsF.collect { case (tx: Transactions.UnsignedHtlcTimeoutTx, remoteSig) => (tx, remoteSig) } @@ -478,10 +482,15 @@ abstract class AnchorChannelIntegrationSpec extends ChannelIntegrationSpec { assert(initialStateDataF.commitments.latest.commitmentFormat == channelType.commitmentFormat) val initialCommitmentIndex = initialStateDataF.commitments.localCommitIndex - val toRemoteAddress = { - val channelKeys = nodes("F").nodeParams.channelKeyManager.channelKeys(initialStateDataF.channelParams.channelConfig, initialStateDataF.channelParams.localParams.fundingKeyPath) - val toRemote = Scripts.toRemoteDelayed(initialStateDataF.commitments.latest.localKeys(channelKeys).publicKeys) - Script.write(Script.pay2wsh(toRemote)) + val toRemoteAddress = channelType.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat => + val channelKeys = nodes("F").nodeParams.channelKeyManager.channelKeys(initialStateDataF.channelParams.channelConfig, initialStateDataF.channelParams.localParams.fundingKeyPath) + val toRemote = Scripts.toRemoteDelayed(initialStateDataF.commitments.latest.localKeys(channelKeys).publicKeys) + Script.write(Script.pay2wsh(toRemote)) + case ZeroFeeCommitmentFormat => + val channelKeys = nodes("F").nodeParams.channelKeyManager.channelKeys(initialStateDataF.channelParams.channelConfig, initialStateDataF.channelParams.localParams.fundingKeyPath) + Script.write(Script.pay2wpkh(initialStateDataF.commitments.latest.localKeys(channelKeys).theirPaymentPublicKey)) + case _: SimpleTaprootChannelCommitmentFormat => ??? } // let's make a payment to advance the commit index @@ -534,8 +543,11 @@ abstract class AnchorChannelIntegrationSpec extends ChannelIntegrationSpec { generateBlocks(2) val mainOutputC = OutPoint(commitTx, commitTx.txOut.indexWhere(_.publicKeyScript == toRemoteAddress)) awaitCond({ + bitcoinClient.isTransactionOutputSpent(mainOutputC.txid, mainOutputC.index.toInt).pipeTo(sender.ref) + val alreadySpent = sender.expectMsgType[Boolean] bitcoinClient.getMempool().pipeTo(sender.ref) - sender.expectMsgType[Seq[Transaction]].exists(_.txIn.head.outPoint == mainOutputC) + val mempoolTxs = sender.expectMsgType[Seq[Transaction]] + alreadySpent || mempoolTxs.exists(_.txIn.head.outPoint == mainOutputC) }, max = 20 seconds, interval = 1 second) // get the claim-remote-output confirmed, then the channel can go to the CLOSED state @@ -609,7 +621,7 @@ class AnchorOutputZeroFeeHtlcTxsChannelIntegrationSpec extends AnchorChannelInte testDownstreamTimeoutRemoteCommit() } - test("punish a node that has published a revoked commit tx (anchor outputs)") { + test("punish a node that has published a revoked commit tx (anchor outputs zero fee htlc txs)") { testPunishRevokedCommit() } @@ -617,4 +629,40 @@ class AnchorOutputZeroFeeHtlcTxsChannelIntegrationSpec extends AnchorChannelInte class AnchorOutputZeroFeeHtlcTxsChannelWithEclairSignerIntegrationSpec extends AnchorOutputZeroFeeHtlcTxsChannelIntegrationSpec { override def useEclairSigner: Boolean = true +} + +class ZeroFeeCommitmentChannelIntegrationSpec extends AnchorChannelIntegrationSpec { + + override val channelType: SupportedChannelType = ChannelTypes.ZeroFeeCommitments() + + test("start eclair nodes") { + instantiateEclairNode("A", ConfigFactory.parseMap(Map("eclair.node-alias" -> "A", "eclair.channel.expiry-delta-blocks" -> 40, "eclair.channel.fulfill-safety-before-timeout-blocks" -> 12, "eclair.server.port" -> (if (useEclairSigner) 29843 else 29743), "eclair.api.port" -> (if (useEclairSigner) 28193 else 28093)).asJava).withFallback(withZeroFeeCommitments).withFallback(commonConfig)) + instantiateEclairNode("C", ConfigFactory.parseMap(Map("eclair.node-alias" -> "C", "eclair.channel.expiry-delta-blocks" -> 40, "eclair.channel.fulfill-safety-before-timeout-blocks" -> 12, "eclair.server.port" -> (if (useEclairSigner) 29844 else 29744), "eclair.api.port" -> (if (useEclairSigner) 28194 else 28094)).asJava).withFallback(withZeroFeeCommitments).withFallback(commonConfig)) + instantiateEclairNode("F", ConfigFactory.parseMap(Map("eclair.node-alias" -> "F", "eclair.channel.expiry-delta-blocks" -> 40, "eclair.channel.fulfill-safety-before-timeout-blocks" -> 12, "eclair.server.port" -> (if (useEclairSigner) 29845 else 29745), "eclair.api.port" -> (if (useEclairSigner) 28195 else 28095)).asJava).withFallback(withZeroFeeCommitments).withFallback(commonConfig)) + } + + test("connect nodes") { + connectNodes() + } + + test("open channel C <-> F, send payments and close (zero-fee commitments)") { + testOpenPayClose() + } + + test("propagate a fulfill upstream when a downstream htlc is redeemed on-chain (local commit, zero-fee commitments)") { + testDownstreamFulfillLocalCommit() + } + + test("propagate a fulfill upstream when a downstream htlc is redeemed on-chain (remote commit, zero-fee commitments)") { + testDownstreamFulfillRemoteCommit() + } + + test("propagate a failure upstream when a downstream htlc times out (local commit, zero-fee commitments)") { + testDownstreamTimeoutLocalCommit() + } + + test("propagate a failure upstream when a downstream htlc times out (remote commit, zero-fee commitments)") { + testDownstreamTimeoutRemoteCommit() + } + } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala index d9bdd08397..03f303dc66 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala @@ -113,6 +113,10 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit s"eclair.features.${AnchorOutputsZeroFeeHtlcTx.rfcName}" -> "optional" ).asJava).withFallback(commonFeatures) + val withZeroFeeCommitments = ConfigFactory.parseMap(Map( + s"eclair.features.${ZeroFeeCommitments.rfcName}" -> "optional" + ).asJava).withFallback(commonFeatures) + val withDualFunding = ConfigFactory.parseMap(Map( s"eclair.features.${DualFunding.rfcName}" -> "optional" ).asJava).withFallback(commonFeatures) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PerformanceIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PerformanceIntegrationSpec.scala index 50958aefa5..22709deef6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PerformanceIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PerformanceIntegrationSpec.scala @@ -21,7 +21,6 @@ import akka.testkit.TestProbe import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.SatoshiLong import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived @@ -45,7 +44,7 @@ class PerformanceIntegrationSpec extends IntegrationSpec { test("start eclair nodes") { val commonPerfTestConfig = ConfigFactory.parseMap(Map( - "eclair.channel.max-accepted-htlcs" -> Channel.MAX_ACCEPTED_HTLCS, + "eclair.channel.max-accepted-htlcs" -> 483, "eclair.file-backup.enabled" -> false, ).asJava) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala index b1b794ba60..c2e12e5136 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala @@ -70,6 +70,7 @@ class PeerSpec extends FixtureSpec { import com.softwaremill.quicklens._ val aliceParams = TestConstants.Alice.nodeParams .modify(_.features).setToIf(testData.tags.contains(ChannelStateTestsTags.AnchorOutputsPhoenix))(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional)) + .modify(_.features).setToIf(testData.tags.contains(ChannelStateTestsTags.ZeroFeeCommitments))(Features(ZeroFeeCommitments -> Optional)) .modify(_.features).setToIf(testData.tags.contains(ChannelStateTestsTags.DualFunding))(Features(StaticRemoteKey -> Optional, AnchorOutputs -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, DualFunding -> Optional)) .modify(_.channelConf.maxHtlcValueInFlightMsat).setToIf(testData.tags.contains("max-htlc-value-in-flight-percent"))(100_000_000 msat) .modify(_.channelConf.maxHtlcValueInFlightPercent).setToIf(testData.tags.contains("max-htlc-value-in-flight-percent"))(25) @@ -569,6 +570,24 @@ class PeerSpec extends FixtureSpec { assert(init.fundingTxFeerate == nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing)) } + test("use correct on-chain fee rates when spawning a channel (zero-fee commitments)", Tag(ChannelStateTestsTags.ZeroFeeCommitments)) { f => + import f._ + + val probe = TestProbe() + connect(remoteNodeId, peer, peerConnection, switchboard, remoteInit = protocol.Init(Features(ZeroFeeCommitments -> Optional))) + assert(peer.stateData.channels.isEmpty) + + // We ensure the current network feerate is higher than the default anchor output feerate. + nodeParams.setBitcoinCoreFeerates(FeeratesPerKw.single(TestConstants.anchorOutputsFeeratePerKw * 2).copy(minimum = FeeratePerKw(250 sat))) + probe.send(peer, Peer.OpenChannel(remoteNodeId, 15000 sat, Some(ChannelTypes.ZeroFeeCommitments()), None, None, None, None, None, None)) + val init = channel.expectMsgType[INPUT_INIT_CHANNEL_INITIATOR] + assert(init.channelType == ChannelTypes.ZeroFeeCommitments()) + assert(!init.dualFunded) + assert(init.fundingAmount == 15000.sat) + assert(init.commitTxFeerate == FeeratePerKw(0 sat)) + assert(init.fundingTxFeerate == nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing)) + } + test("compute max-htlc-value-in-flight based on funding amount", Tag("max-htlc-value-in-flight-percent")) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala index 9ce0d162b3..d6eb441fc1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TestVectorsSpec.scala @@ -18,17 +18,20 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Script, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxId, TxOut} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol.UpdateAddHtlc -import fr.acinq.eclair.{ChannelTypeFeature, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, TestConstants} +import fr.acinq.eclair.{ChannelTypeFeature, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, TestConstants, randomKey} import grizzled.slf4j.Logging +import org.json4s.DefaultFormats +import org.json4s.jackson.JsonMethods import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ +import java.io.File import scala.io.Source trait TestVectorsSpec extends AnyFunSuite with Logging { @@ -214,6 +217,7 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { val outputs = Transactions.makeCommitTxOutputs( Local.funding_pubkey, Remote.funding_pubkey, + commitmentInput, localCommitmentKeys.publicKeys, payCommitTxFees = true, dustLimit, @@ -228,6 +232,7 @@ trait TestVectorsSpec extends AnyFunSuite with Logging { localPaymentBasePoint = Local.payment_basepoint, remotePaymentBasePoint = Remote.payment_basepoint, localIsChannelOpener = true, + commitmentFormat = commitmentFormat, outputs = outputs) val local_sig = tx.sign(Local.funding_privkey, Remote.funding_pubkey) logger.info(s"# local_signature = ${Scripts.der(local_sig.sig).dropRight(1).toHex}") @@ -455,3 +460,105 @@ class AnchorOutputsZeroFeeHtlcTxTestVectorSpec extends TestVectorsSpec { override def channelFeatures: Set[ChannelTypeFeature] = Set(Features.StaticRemoteKey, Features.AnchorOutputsZeroFeeHtlcTx) // @formatter:on } + +class ZeroFeeCommitmentsTestVectorSpec extends AnyFunSuite { + + implicit val formats: DefaultFormats.type = DefaultFormats + + case class TestFixture(local_funding_priv: String, + remote_funding_priv: String, + funding_txid: String, + funding_index: Long, + funding_amount_satoshis: Long, + commitment_number: Long, + to_self_delay: Int, + local_payment_basepoint_secret: String, + local_delayed_payment_basepoint_secret: String, + local_htlc_basepoint_secret: String, + per_commitment_point: String, + remote_payment_basepoint_secret: String, + remote_htlc_basepoint_secret: String, + revocation_basepoint: String, + payment_hash_to_preimage: Map[String, String], + tests: Seq[TestVector]) { + val commitmentFormat: CommitmentFormat = ZeroFeeCommitmentFormat + val localFundingPriv: PrivateKey = PrivateKey(ByteVector.fromValidHex(local_funding_priv)) + val remoteFundingPriv: PrivateKey = PrivateKey(ByteVector.fromValidHex(remote_funding_priv)) + val fundingOutput: OutPoint = OutPoint(TxId.fromValidHex(funding_txid), funding_index) + val fundingAmount: Satoshi = Satoshi(funding_amount_satoshis) + val commitTxNumber: Long = commitment_number + val toSelfDelay: CltvExpiryDelta = CltvExpiryDelta(to_self_delay) + val commitmentPoint: PublicKey = PublicKey(ByteVector.fromValidHex(per_commitment_point)) + // Private keys used to generate and sign our local commitment transaction and HTLC transactions. + val localKeys: LocalCommitmentKeys = LocalCommitmentKeys( + ourDelayedPaymentKey = ChannelKeys.derivePerCommitmentKey(PrivateKey(ByteVector.fromValidHex(local_delayed_payment_basepoint_secret)), commitmentPoint), + theirPaymentPublicKey = PrivateKey(ByteVector.fromValidHex(remote_payment_basepoint_secret)).publicKey, + ourPaymentBasePoint = PrivateKey(ByteVector.fromValidHex(local_payment_basepoint_secret)).publicKey, + ourHtlcKey = ChannelKeys.derivePerCommitmentKey(PrivateKey(ByteVector.fromValidHex(local_htlc_basepoint_secret)), commitmentPoint), + theirHtlcPublicKey = ChannelKeys.remotePerCommitmentPublicKey(PrivateKey(ByteVector.fromValidHex(remote_htlc_basepoint_secret)).publicKey, commitmentPoint), + revocationPublicKey = ChannelKeys.revocationPublicKey(PublicKey(ByteVector.fromValidHex(revocation_basepoint)), commitmentPoint) + ) + // Keys used to sign our HTLC transactions by the remote peer. + val remoteKeys: RemoteCommitmentKeys = RemoteCommitmentKeys( + ourPaymentKey = PrivateKey(ByteVector.fromValidHex(remote_payment_basepoint_secret)), + theirDelayedPaymentPublicKey = localKeys.ourDelayedPaymentKey.publicKey, + ourPaymentBasePoint = PrivateKey(ByteVector.fromValidHex(remote_payment_basepoint_secret)).publicKey, + ourHtlcKey = ChannelKeys.derivePerCommitmentKey(PrivateKey(ByteVector.fromValidHex(remote_htlc_basepoint_secret)), commitmentPoint), + theirHtlcPublicKey = localKeys.ourHtlcKey.publicKey, + revocationPublicKey = localKeys.revocationPublicKey + ) + val preimages: Map[ByteVector32, ByteVector32] = payment_hash_to_preimage.map { case (h, p) => (ByteVector32.fromValidHex(h), ByteVector32.fromValidHex(p)) } + } + + case class TestHtlc(id: Long, amount_msat: Long, payment_hash: String, cltv_expiry: Long) { + val htlc: UpdateAddHtlc = UpdateAddHtlc(ByteVector32.Zeroes, id, MilliSatoshi(amount_msat), ByteVector32.fromValidHex(payment_hash), CltvExpiry(cltv_expiry), TestConstants.emptyOnionPacket, None, 0, None) + } + + case class TestVector(name: String, + dust_limit_satoshis: Long, + to_local_msat: Long, + to_remote_msat: Long, + incoming_htlcs: Seq[TestHtlc], + outgoing_htlcs: Seq[TestHtlc], + signed_commit_tx: String, + signed_htlc_success_txs: Seq[String], + signed_htlc_timeout_txs: Seq[String]) { + val dustLimit: Satoshi = Satoshi(dust_limit_satoshis) + val spec = CommitmentSpec( + htlcs = incoming_htlcs.map(i => IncomingHtlc(i.htlc)).toSet ++ outgoing_htlcs.map(o => OutgoingHtlc(o.htlc)), + commitTxFeerate = FeeratePerKw(0 sat), + toLocal = MilliSatoshi(to_local_msat), + toRemote = MilliSatoshi(to_remote_msat), + ) + } + + test("zero-fee-commitments (Bolt3 reference test vector)") { + val src = Source.fromFile(new File(getClass.getResource("/bolt3-tx-test-vectors-zero-fee-commitment-format.json").getFile)) + val f = JsonMethods.parse(src.mkString).extract[TestFixture] + src.close() + import f._ + + val fundingInfo = makeFundingScript(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitmentFormat) + val commitInput = makeFundingInputInfo(fundingOutput.txid, fundingOutput.index.toInt, fundingAmount, localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitmentFormat) + + tests.foreach(t => { + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, t.dustLimit, toSelfDelay, t.spec, commitmentFormat) + val txInfo = makeCommitTx(commitInput, commitTxNumber, localKeys.ourPaymentBasePoint, remoteKeys.ourPaymentBasePoint, localIsChannelOpener = true, commitmentFormat, outputs) + val localSig = txInfo.sign(localFundingPriv, remoteFundingPriv.publicKey) + val remoteSig = txInfo.sign(remoteFundingPriv, localFundingPriv.publicKey) + val commitTx = txInfo.aggregateSigs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localSig, remoteSig) + Transaction.correctlySpends(commitTx, Map(fundingOutput -> TxOut(fundingAmount, fundingInfo.pubkeyScript)), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + assert(commitTx.toString() == t.signed_commit_tx) + val htlcTxs = makeHtlcTxs(commitTx, outputs, commitmentFormat).map(htlcTx => { + val remoteSig = htlcTx.localSig(remoteKeys) + htlcTx match { + case htlcTx: UnsignedHtlcSuccessTx => htlcTx.addRemoteSig(localKeys, remoteSig, preimages(htlcTx.paymentHash)).sign() + case htlcTx: UnsignedHtlcTimeoutTx => htlcTx.addRemoteSig(localKeys, remoteSig).sign() + } + }) + htlcTxs.foreach(tx => Transaction.correctlySpends(tx, Seq(commitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + assert(htlcTxs.map(_.toString()).toSet == t.signed_htlc_success_txs.toSet ++ t.signed_htlc_timeout_txs.toSet) + }) + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index 097151aaea..a17c6d1280 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -150,7 +150,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { private def checkExpectedWeight(actual: Int, expected: Int, commitmentFormat: CommitmentFormat): Unit = { commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => assert(actual == expected) - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => // ECDSA signatures are der-encoded, which creates some variability in signature size compared to the baseline. assert(actual <= expected + 4) assert(actual >= expected - 4) @@ -158,13 +158,14 @@ class TransactionsSpec extends AnyFunSuite with Logging { } test("generate valid commitment with some outputs that don't materialize (anchor outputs)") { + val commitInput = InputInfo(OutPoint(randomTxId(), 0), TxOut(700.millibtc.toSatoshi, Script.pay2wpkh(randomKey().publicKey))) val spec = CommitmentSpec(htlcs = Set.empty, commitTxFeerate = feeratePerKw, toLocal = 400.millibtc.toMilliSatoshi, toRemote = 300.millibtc.toMilliSatoshi) val commitFeeAndAnchorCost = commitTxTotalCost(localDustLimit, spec, UnsafeLegacyAnchorOutputsCommitmentFormat) val belowDust = (localDustLimit * 0.9).toMilliSatoshi val belowDustWithFeeAndAnchors = (localDustLimit + commitFeeAndAnchorCost * 0.9).toMilliSatoshi { - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, spec, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, spec, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.size == 4) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToLocalAnchor]).get.txOut.amount == anchorAmount) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToRemoteAnchor]).get.txOut.amount == anchorAmount) @@ -173,35 +174,35 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { val toRemoteFundeeBelowDust = spec.copy(toRemote = belowDust) - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, toRemoteFundeeBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, toRemoteFundeeBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.size == 2) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToLocalAnchor]).get.txOut.amount == anchorAmount) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToLocal]).get.txOut.amount.toMilliSatoshi == spec.toLocal - commitFeeAndAnchorCost) } { val toLocalFunderBelowDust = spec.copy(toLocal = belowDustWithFeeAndAnchors) - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, toLocalFunderBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, toLocalFunderBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.size == 2) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToRemoteAnchor]).get.txOut.amount == anchorAmount) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToRemote]).get.txOut.amount.toMilliSatoshi == spec.toRemote) } { val toRemoteFunderBelowDust = spec.copy(toRemote = belowDustWithFeeAndAnchors) - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = false, localDustLimit, toLocalDelay, toRemoteFunderBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = false, localDustLimit, toLocalDelay, toRemoteFunderBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.size == 2) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToLocalAnchor]).get.txOut.amount == anchorAmount) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToLocal]).get.txOut.amount.toMilliSatoshi == spec.toLocal) } { val toLocalFundeeBelowDust = spec.copy(toLocal = belowDust) - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = false, localDustLimit, toLocalDelay, toLocalFundeeBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = false, localDustLimit, toLocalDelay, toLocalFundeeBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.size == 2) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToRemoteAnchor]).get.txOut.amount == anchorAmount) assert(outputs.find(_.isInstanceOf[CommitmentOutput.ToRemote]).get.txOut.amount.toMilliSatoshi == spec.toRemote - commitFeeAndAnchorCost) } { val allBelowDust = spec.copy(toLocal = belowDust, toRemote = belowDust) - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, allBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, allBelowDust, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(outputs.isEmpty) } } @@ -252,8 +253,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val (commitTx, commitTxOutputs, htlcTimeoutTxs, htlcSuccessTxs) = { val commitTxNumber = 0x404142434445L - val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, spec, commitmentFormat) - val txInfo = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsChannelOpener = true, outputs) + val outputs = makeCommitTxOutputs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, commitInput, localKeys.publicKeys, payCommitTxFees = true, localDustLimit, toLocalDelay, spec, commitmentFormat) + val txInfo = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsChannelOpener = true, commitmentFormat, outputs) val commitTx = commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val Right(commitTx) = for { @@ -265,7 +266,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { tx <- txInfo.aggregateSigs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localPartialSig, remotePartialSig) } yield tx commitTx - case _: AnchorOutputsCommitmentFormat => + case _: SegwitV0CommitmentFormat => val localSig = txInfo.sign(localFundingPriv, remoteFundingPriv.publicKey) val remoteSig = txInfo.sign(remoteFundingPriv, localFundingPriv.publicKey) assert(txInfo.checkRemoteSig(localFundingPubkey = localFundingPriv.publicKey, remoteFundingPriv.publicKey, remoteSig)) @@ -283,14 +284,14 @@ class TransactionsSpec extends AnyFunSuite with Logging { val htlcSuccessTxs = htlcTxs.collect { case tx: UnsignedHtlcSuccessTx => tx } val htlcTimeoutTxs = htlcTxs.collect { case tx: UnsignedHtlcTimeoutTx => tx } commitmentFormat match { - case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat | ZeroFeeCommitmentFormat => assert(htlcTxs.length == 7) assert(expiries == Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300, 7 -> 300, 8 -> 302)) assert(htlcTimeoutTxs.size == 3) // htlc1 and htlc3 and htlc7 assert(htlcTimeoutTxs.map(_.htlcId).toSet == Set(0, 3, 7)) assert(htlcSuccessTxs.size == 4) // htlc2a, htlc2b, htlc4 and htlc8 assert(htlcSuccessTxs.map(_.htlcId).toSet == Set(1, 2, 4, 8)) - case _ => + case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => assert(htlcTxs.length == 5) assert(expiries == Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300)) assert(htlcTimeoutTxs.size == 2) // htlc1 and htlc3 @@ -300,6 +301,16 @@ class TransactionsSpec extends AnyFunSuite with Logging { } (commitTx, outputs, htlcTimeoutTxs, htlcSuccessTxs) } + commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => + assert(commitTx.version == 2) + htlcTimeoutTxs.foreach(htlcTx => assert(htlcTx.tx.version == 2)) + htlcSuccessTxs.foreach(htlcTx => assert(htlcTx.tx.version == 2)) + case ZeroFeeCommitmentFormat => + assert(commitTx.version == 3) + htlcTimeoutTxs.foreach(htlcTx => assert(htlcTx.tx.version == 3)) + htlcSuccessTxs.foreach(htlcTx => assert(htlcTx.tx.version == 3)) + } { // local spends main delayed output @@ -309,7 +320,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // remote spends main delayed output - val Right(claimRemoteDelayedOutputTx) = ClaimRemoteDelayedOutputTx.createUnsignedTx(remoteKeys, commitTx, localDustLimit, finalPubKeyScript, feeratePerKw, commitmentFormat).map(_.sign()) + val Right(claimRemoteDelayedOutputTx) = ClaimRemoteMainOutputTx.createUnsignedTx(remoteKeys, commitTx, localDustLimit, finalPubKeyScript, feeratePerKw, commitmentFormat).map(_.sign()) checkExpectedWeight(claimRemoteDelayedOutputTx.weight(), commitmentFormat.toRemoteWeight, commitmentFormat) Transaction.correctlySpends(claimRemoteDelayedOutputTx, Seq(commitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } @@ -339,7 +350,11 @@ class TransactionsSpec extends AnyFunSuite with Logging { { // remote spends remote anchor val Right(claimAnchorOutputTx) = ClaimRemoteAnchorTx.createUnsignedTx(remoteFundingPriv, remoteKeys, commitTx, commitmentFormat) - assert(!claimAnchorOutputTx.validate(Map.empty)) + commitmentFormat match { + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(!claimAnchorOutputTx.validate(Map.empty)) + // Standard P2A outputs are valid with an empty witness. + case ZeroFeeCommitmentFormat => assert(claimAnchorOutputTx.validate(Map.empty)) + } val signedTx = claimAnchorOutputTx.sign() Transaction.correctlySpends(signedTx, Seq(commitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } @@ -447,13 +462,13 @@ class TransactionsSpec extends AnyFunSuite with Logging { val skipped = claimHtlcDelayedPenaltyTxs.collect { case Left(reason) => reason } val claimed = claimHtlcDelayedPenaltyTxs.collect { case Right(tx) => tx } commitmentFormat match { - case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat | ZeroFeeCommitmentFormat => assert(claimHtlcDelayedPenaltyTxs.size == 7) assert(skipped.size == 2) assert(skipped.toSet == Set(AmountBelowDustLimit)) assert(claimed.size == 5) assert(claimed.map(_.input.outPoint).toSet.size == 5) - case _ => + case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => assert(claimHtlcDelayedPenaltyTxs.size == 5) assert(skipped.size == 2) assert(skipped.toSet == Set(AmountBelowDustLimit)) @@ -500,6 +515,10 @@ class TransactionsSpec extends AnyFunSuite with Logging { testCommitAndHtlcTxs(PhoenixSimpleTaprootChannelCommitmentFormat) } + test("generate valid commitment and htlc transactions (zero-fee commitments)") { + testCommitAndHtlcTxs(ZeroFeeCommitmentFormat) + } + test("generate taproot NUMS point") { val bin = 2.toByte +: Crypto.sha256(ByteVector.fromValidHex("0000000000000002") ++ ByteVector.view("Lightning Simple Taproot".getBytes)) val pub = PublicKey(bin) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala index d598dc2f97..3f0ebec73b 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala @@ -41,6 +41,10 @@ trait Channel { ChannelTypes.SimpleTaprootChannelsStaging(zeroConf = true), ChannelTypes.SimpleTaprootChannelsStaging(scidAlias = true), ChannelTypes.SimpleTaprootChannelsStaging(scidAlias = true, zeroConf = true), + ChannelTypes.ZeroFeeCommitments(), + ChannelTypes.ZeroFeeCommitments(zeroConf = true), + ChannelTypes.ZeroFeeCommitments(scidAlias = true), + ChannelTypes.ZeroFeeCommitments(scidAlias = true, zeroConf = true), ).map(ct => ct.toString -> ct).toMap // we use the toString method as name in the api val open: Route = postRequest("open") { implicit t =>