From 575f5dff70e5e9d36fddf73c95d4fc74b1cfffc9 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 11 Jun 2025 16:37:20 +0200 Subject: [PATCH 1/4] Use official splice messages We replace our experimental version of `splice_init`, `splice_ack` and `splice_locked` by their official version. If our peer is using the experimental feature bit, we convert our outgoing messages to use the experimental encoding and incoming messages to the official messages. We also change the TLV fields added to `tx_add_input`, `tx_signatures` and `splice_locked` to match the spec version. We always write both the official and experimental TLV to updated nodes (because the experimental one is odd and will be ignored) but we drop the official TLV if our peer is using the experimental feature, because it won't understand the even TLV field. We do the same thing for the `commit_sig` TLV. For peers who support the official splicing version, we insert the `start_batch` message before the batch of `commit_sig` messages. This guarantees backwards-compatibility with peers who only support the experimental feature. --- docs/release-notes/eclair-vnext.md | 33 +++++++++++ eclair-core/src/main/resources/reference.conf | 1 + .../main/scala/fr/acinq/eclair/Features.scala | 10 +++- .../fr/acinq/eclair/channel/fsm/Channel.scala | 8 ++- .../fr/acinq/eclair/io/PeerConnection.scala | 22 +++++++- .../wire/protocol/InteractiveTxTlv.scala | 12 +++- .../protocol/LightningMessageCodecs.scala | 28 +++++++++- .../wire/protocol/LightningMessageTypes.scala | 41 +++++++++++++- .../scala/fr/acinq/eclair/TestConstants.scala | 4 +- .../ChannelStateTestsHelperMethods.scala | 2 +- .../basic/channel/GossipIntegrationSpec.scala | 2 - .../io/OpenChannelInterceptorSpec.scala | 4 +- .../acinq/eclair/io/PeerConnectionSpec.scala | 55 ++++++++++++++++++- .../payment/relay/OnTheFlyFundingSpec.scala | 6 +- .../protocol/LightningMessageCodecsSpec.scala | 41 +++++++------- 15 files changed, 220 insertions(+), 49 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 9ec1a25923..3d19f22128 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -9,6 +9,39 @@ With this release, eclair requires using Bitcoin Core 30.x. Newer versions of Bitcoin Core may be used, but have not been extensively tested. +### Channel Splicing + +With this release, we add support for the final version of [splicing](https://github.com/lightning/bolts/pull/1160) that was recently added to the BOLTs. +Splicing allows node operators to change the size of their existing channels, which makes it easier and more efficient to allocate liquidity where it is most needed. +Most node operators can now have a single channel with each of their peer, which costs less on-chain fees and resources, and makes path-finding easier. + +The size of an existing channel can be increased with the `splicein` API: + +```sh +eclair-cli splicein --channelId= --amountIn= +``` + +Once that transaction confirms, the additional liquidity can be used to send outgoing payments. +If the transaction doesn't confirm, the node operator can speed up confirmation with the `rbfsplice` API: + +```sh +eclair-cli rbfsplice --channelId= --targetFeerateSatByte= --fundingFeeBudgetSatoshis= +``` + +If the node operator wants to reduce the size of a channel, or send some of the channel funds to an on-chain address, they can use the `spliceout` API: + +```sh +eclair-cli spliceout --channelId= --amountOut= --scriptPubKey= +``` + +That operation can also be RBF-ed with the `rbfsplice` API to speed up confirmation if necessary. + +Note that when 0-conf is used for the channel, it is not possible to RBF splice transactions. +Node operators should instead create a new splice transaction (with `splicein` or `spliceout`) to CPFP the previous transaction. + +Note that eclair had already introduced support for a splicing prototype in v0.9.0, which helped improve the BOLT proposal. +We're removing support for the previous splicing prototype feature: users that depended on this protocol must upgrade to create official splice transactions. + ### Remove support for non-anchor channels We remove the code used to support legacy channels that don't use anchor outputs or taproot. diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 0c51fc91ca..29033d5b62 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -88,6 +88,7 @@ eclair { option_zeroconf = disabled keysend = disabled option_simple_close = optional + option_splice = optional trampoline_payment_prototype = disabled async_payment_prototype = disabled on_the_fly_funding = 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 3035c24cd0..2325beaf3b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -344,8 +344,7 @@ object Features { val mandatory = 28 } - // TODO: this should also extend NodeFeature once the spec is finalized - case object Quiescence extends Feature with InitFeature { + case object Quiescence extends Feature with InitFeature with NodeFeature { val rfcName = "option_quiesce" val mandatory = 34 } @@ -395,6 +394,11 @@ object Features { val mandatory = 60 } + case object Splicing extends Feature with InitFeature with NodeFeature { + val rfcName = "option_splice" + val mandatory = 62 + } + case object PhoenixZeroReserve extends Feature with InitFeature with ChannelTypeFeature with PermanentChannelFeature { val rfcName = "phoenix_zero_reserve" val mandatory = 128 @@ -482,6 +486,7 @@ object Features { ZeroConf, KeySend, SimpleClose, + Splicing, SimpleTaprootChannelsPhoenix, SimpleTaprootChannelsStaging, WakeUpNotificationClient, @@ -506,7 +511,6 @@ object Features { SimpleClose -> (ShutdownAnySegwit :: Nil), SimpleTaprootChannelsPhoenix -> (ChannelType :: SimpleClose :: Nil), AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil), - OnTheFlyFunding -> (SplicePrototype :: Nil), FundingFeeCredit -> (OnTheFlyFunding :: Nil) ) 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 7b2586071a..29cfe9ee47 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 @@ -955,7 +955,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(cmd: CMD_SPLICE, d: DATA_NORMAL) => - if (!d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype)) { + if (!d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.Splicing) && !d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype)) { log.warning("cannot initiate splice, peer doesn't support splicing") cmd.replyTo ! RES_FAILURE(cmd, CommandUnavailableInThisState(d.channelId, "splice", NORMAL)) stay() @@ -2474,7 +2474,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case _ => Set.empty } - val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype)) { + val remoteFeatures = d.commitments.remoteChannelParams.initFeatures + val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.hasFeature(Features.SplicePrototype)) { d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++ d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet } else Set.empty @@ -3398,7 +3399,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // We only send channel_ready for initial funding transactions. case Some(c) if c.fundingTxIndex != 0 => (None, None) case Some(c) => - val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) + val remoteFeatures = d.commitments.remoteChannelParams.initFeatures + val remoteSpliceSupport = remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.hasFeature(Features.SplicePrototype) // If our peer has not received our channel_ready, we retransmit it. val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala index 12aa1132f1..4a81d38cc4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerConnection.scala @@ -207,12 +207,20 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A stay() case Event(msg: LightningMessage, d: ConnectedData) if sender() != d.transport => // if the message doesn't originate from the transport, it is an outgoing message + val useExperimentalSplice = d.remoteInit.features.hasFeature(Features.SplicePrototype) msg match { + // If our peer is using the experimental splice version, we convert splice messages. + case msg: SpliceInit if useExperimentalSplice => d.transport forward ExperimentalSpliceInit.from(msg) + case msg: SpliceAck if useExperimentalSplice => d.transport forward ExperimentalSpliceAck.from(msg) + case msg: SpliceLocked if useExperimentalSplice => d.transport forward ExperimentalSpliceLocked.from(msg) + case msg: TxAddInput if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxAddInputTlv.SharedInputTxId]))) + case msg: TxSignatures if useExperimentalSplice => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[TxSignaturesTlv.PreviousFundingTxSig]))) + case batch: CommitSigBatch if useExperimentalSplice => batch.messages.foreach(msg => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.FundingTx])))) case batch: CommitSigBatch => // We insert a start_batch message to let our peer know how many commit_sig they will receive. d.transport forward StartBatch.commitSigBatch(batch.channelId, batch.batchSize) - batch.messages.foreach(msg => d.transport forward msg) - case msg => d.transport forward msg + batch.messages.foreach(msg => d.transport forward msg.copy(tlvStream = TlvStream(msg.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.ExperimentalBatchTlv])))) + case _ => d.transport forward msg } msg match { // If we send any channel management message to this peer, the connection should be persistent. @@ -426,6 +434,16 @@ class PeerConnection(keyPair: KeyPair, conf: PeerConnection.Conf, switchboard: A d.peer ! msg stay() } + // If our peer is using the experimental splice version, we convert splice messages. + case msg: ExperimentalSpliceInit => + d.peer ! msg.toSpliceInit + stay() + case msg: ExperimentalSpliceAck => + d.peer ! msg.toSpliceAck + stay() + case msg: ExperimentalSpliceLocked => + d.peer ! msg.toSpliceLocked + stay() case _ => d.peer ! msg stay() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala index f8ae3d15a8..3eb0c34b98 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala @@ -36,6 +36,9 @@ object TxAddInputTlv { /** When doing a splice, the initiator must provide the previous funding txId instead of the whole transaction. */ case class SharedInputTxId(txId: TxId) extends TxAddInputTlv + /** Same as [[SharedInputTxId]] for peers who only support the experimental version of splicing. */ + case class ExperimentalSharedInputTxId(txId: TxId) extends TxAddInputTlv + /** * When creating an interactive-tx where both participants sign a taproot input, we don't need to provide the entire * previous transaction in [[TxAddInput]]: signatures will commit to the txOut of *all* of the transaction's inputs, @@ -49,7 +52,8 @@ object TxAddInputTlv { val txAddInputTlvCodec: Codec[TlvStream[TxAddInputTlv]] = tlvStream(discriminated[TxAddInputTlv].by(varint) // Note that we actually encode as a tx_hash to be consistent with other lightning messages. - .typecase(UInt64(1105), tlvField(txIdAsHash.as[SharedInputTxId])) + .typecase(UInt64(0), tlvField(txIdAsHash.as[SharedInputTxId])) + .typecase(UInt64(1105), tlvField(txIdAsHash.as[ExperimentalSharedInputTxId])) .typecase(UInt64(1111), PrevTxOut.codec) ) } @@ -102,9 +106,13 @@ object TxSignaturesTlv { /** When doing a splice for a taproot channel, each peer must provide their partial signature for the previous musig2 funding output. */ case class PreviousFundingTxPartialSig(partialSigWithNonce: PartialSignatureWithNonce) extends TxSignaturesTlv + /** Same as [[PreviousFundingTxSig]] for peers who only support the experimental version of splicing. */ + case class ExperimentalPreviousFundingTxSig(sig: ByteVector64) extends TxSignaturesTlv + val txSignaturesTlvCodec: Codec[TlvStream[TxSignaturesTlv]] = tlvStream(discriminated[TxSignaturesTlv].by(varint) + .typecase(UInt64(0), tlvField(bytes64.as[PreviousFundingTxSig])) .typecase(UInt64(2), tlvField(partialSignatureWithNonce.as[PreviousFundingTxPartialSig])) - .typecase(UInt64(601), tlvField(bytes64.as[PreviousFundingTxSig])) + .typecase(UInt64(601), tlvField(bytes64.as[ExperimentalPreviousFundingTxSig])) ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index f36c6d7b1f..95797a7080 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -437,17 +437,36 @@ object LightningMessageCodecs { ("fundingPubkey" | publicKey) :: ("tlvStream" | SpliceInitTlv.spliceInitTlvCodec)).as[SpliceInit] + val experimentalSpliceInitCodec: Codec[ExperimentalSpliceInit] = ( + ("channelId" | bytes32) :: + ("fundingContribution" | satoshiSigned) :: + ("feerate" | feeratePerKw) :: + ("lockTime" | uint32) :: + ("fundingPubkey" | publicKey) :: + ("tlvStream" | SpliceInitTlv.spliceInitTlvCodec)).as[ExperimentalSpliceInit] + val spliceAckCodec: Codec[SpliceAck] = ( ("channelId" | bytes32) :: ("fundingContribution" | satoshiSigned) :: ("fundingPubkey" | publicKey) :: ("tlvStream" | SpliceAckTlv.spliceAckTlvCodec)).as[SpliceAck] + val experimentalSpliceAckCodec: Codec[ExperimentalSpliceAck] = ( + ("channelId" | bytes32) :: + ("fundingContribution" | satoshiSigned) :: + ("fundingPubkey" | publicKey) :: + ("tlvStream" | SpliceAckTlv.spliceAckTlvCodec)).as[ExperimentalSpliceAck] + val spliceLockedCodec: Codec[SpliceLocked] = ( ("channelId" | bytes32) :: ("fundingTxHash" | txIdAsHash) :: ("tlvStream" | SpliceLockedTlv.spliceLockedTlvCodec)).as[SpliceLocked] + val experimentalSpliceLockedCodec: Codec[ExperimentalSpliceLocked] = ( + ("channelId" | bytes32) :: + ("fundingTxHash" | txIdAsHash) :: + ("tlvStream" | SpliceLockedTlv.spliceLockedTlvCodec)).as[ExperimentalSpliceLocked] + val stfuCodec: Codec[Stfu] = ( ("channelId" | bytes32) :: ("initiator" | byte.xmap[Boolean](b => b != 0, b => if (b) 1 else 0))).as[Stfu] @@ -530,6 +549,9 @@ object LightningMessageCodecs { .typecase(72, txInitRbfCodec) .typecase(73, txAckRbfCodec) .typecase(74, txAbortCodec) + .typecase(77, spliceLockedCodec) + .typecase(80, spliceInitCodec) + .typecase(81, spliceAckCodec) .typecase(127, startBatchCodec) .typecase(128, updateAddHtlcCodec) .typecase(130, updateFulfillHtlcCodec) @@ -562,9 +584,9 @@ object LightningMessageCodecs { .typecase(41045, addFeeCreditCodec) .typecase(41046, currentFeeCreditCodec) // - .typecase(37000, spliceInitCodec) - .typecase(37002, spliceAckCodec) - .typecase(37004, spliceLockedCodec) + .typecase(37000, experimentalSpliceInitCodec) + .typecase(37002, experimentalSpliceAckCodec) + .typecase(37004, experimentalSpliceLockedCodec) // // diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 0ceb57534e..3867c62123 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -97,13 +97,14 @@ case class TxAddInput(channelId: ByteVector32, tlvStream: TlvStream[TxAddInputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId { /** This field may replace [[previousTx_opt]] when using taproot. */ val previousTxOut_opt: Option[InputInfo] = tlvStream.get[TxAddInputTlv.PrevTxOut].map(tlv => InputInfo(OutPoint(tlv.txId, previousTxOutput), TxOut(tlv.amount, tlv.publicKeyScript))) - val sharedInput_opt: Option[OutPoint] = tlvStream.get[TxAddInputTlv.SharedInputTxId].map(i => OutPoint(i.txId, previousTxOutput)) + val sharedInput_opt: Option[OutPoint] = tlvStream.get[TxAddInputTlv.SharedInputTxId].map(i => OutPoint(i.txId, previousTxOutput)).orElse(tlvStream.get[TxAddInputTlv.ExperimentalSharedInputTxId].map(i => OutPoint(i.txId, previousTxOutput))) } object TxAddInput { def apply(channelId: ByteVector32, serialId: UInt64, sharedInput: OutPoint, sequence: Long): TxAddInput = { val tlvs = Set[TxAddInputTlv]( TxAddInputTlv.SharedInputTxId(sharedInput.txid), + TxAddInputTlv.ExperimentalSharedInputTxId(sharedInput.txid), ) TxAddInput(channelId, serialId, None, sharedInput.index, sequence, TlvStream(tlvs)) } @@ -143,14 +144,14 @@ case class TxSignatures(channelId: ByteVector32, txId: TxId, witnesses: Seq[ScriptWitness], tlvStream: TlvStream[TxSignaturesTlv] = TlvStream.empty) extends InteractiveTxMessage with HasChannelId { - val previousFundingTxSig_opt: Option[ByteVector64] = tlvStream.get[TxSignaturesTlv.PreviousFundingTxSig].map(_.sig) + val previousFundingTxSig_opt: Option[ByteVector64] = tlvStream.get[TxSignaturesTlv.PreviousFundingTxSig].map(_.sig).orElse(tlvStream.get[TxSignaturesTlv.ExperimentalPreviousFundingTxSig].map(_.sig)) val previousFundingTxPartialSig_opt: Option[PartialSignatureWithNonce] = tlvStream.get[TxSignaturesTlv.PreviousFundingTxPartialSig].map(_.partialSigWithNonce) } object TxSignatures { def apply(channelId: ByteVector32, tx: Transaction, witnesses: Seq[ScriptWitness], previousFundingSig_opt: Option[ChannelSpendSignature]): TxSignatures = { val tlvs: Set[TxSignaturesTlv] = previousFundingSig_opt match { - case Some(IndividualSignature(sig)) => Set(TxSignaturesTlv.PreviousFundingTxSig(sig)) + case Some(IndividualSignature(sig)) => Set(TxSignaturesTlv.PreviousFundingTxSig(sig), TxSignaturesTlv.ExperimentalPreviousFundingTxSig(sig)) case Some(partialSig: PartialSignatureWithNonce) => Set(TxSignaturesTlv.PreviousFundingTxPartialSig(partialSig)) case None => Set.empty } @@ -411,6 +412,19 @@ object SpliceInit { apply(channelId, fundingContribution, lockTime, feerate, fundingPubKey, pushAmount, requireConfirmedInputs, requestFunding_opt, None) } +case class ExperimentalSpliceInit(channelId: ByteVector32, + fundingContribution: Satoshi, + feerate: FeeratePerKw, + lockTime: Long, + fundingPubKey: PublicKey, + tlvStream: TlvStream[SpliceInitTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { + def toSpliceInit: SpliceInit = SpliceInit(channelId, fundingContribution, feerate, lockTime, fundingPubKey, tlvStream) +} + +object ExperimentalSpliceInit { + def from(msg: SpliceInit): ExperimentalSpliceInit = ExperimentalSpliceInit(msg.channelId, msg.fundingContribution, msg.feerate, msg.lockTime, msg.fundingPubKey, msg.tlvStream) +} + case class SpliceAck(channelId: ByteVector32, fundingContribution: Satoshi, fundingPubKey: PublicKey, @@ -437,11 +451,32 @@ object SpliceAck { apply(channelId, fundingContribution, fundingPubKey, pushAmount, requireConfirmedInputs, willFund_opt, feeCreditUsed_opt, None) } +case class ExperimentalSpliceAck(channelId: ByteVector32, + fundingContribution: Satoshi, + fundingPubKey: PublicKey, + tlvStream: TlvStream[SpliceAckTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { + def toSpliceAck: SpliceAck = SpliceAck(channelId, fundingContribution, fundingPubKey, tlvStream) +} + +object ExperimentalSpliceAck { + def from(msg: SpliceAck): ExperimentalSpliceAck = ExperimentalSpliceAck(msg.channelId, msg.fundingContribution, msg.fundingPubKey, msg.tlvStream) +} + case class SpliceLocked(channelId: ByteVector32, fundingTxId: TxId, tlvStream: TlvStream[SpliceLockedTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { } +case class ExperimentalSpliceLocked(channelId: ByteVector32, + fundingTxId: TxId, + tlvStream: TlvStream[SpliceLockedTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { + def toSpliceLocked: SpliceLocked = SpliceLocked(channelId, fundingTxId, tlvStream) +} + +object ExperimentalSpliceLocked { + def from(msg: SpliceLocked): ExperimentalSpliceLocked = ExperimentalSpliceLocked(msg.channelId, msg.fundingTxId, msg.tlvStream) +} + case class Shutdown(channelId: ByteVector32, scriptPubKey: ByteVector, tlvStream: TlvStream[ShutdownTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId with ForbiddenMessageWhenQuiescent { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index f3a6b698ab..6e6ea254ad 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -117,7 +117,7 @@ object TestConstants { Features.StaticRemoteKey -> FeatureSupport.Mandatory, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, Features.Quiescence -> FeatureSupport.Optional, - Features.SplicePrototype -> FeatureSupport.Optional, + Features.Splicing -> FeatureSupport.Optional, Features.ProvideStorage -> FeatureSupport.Optional, Features.ChannelType -> FeatureSupport.Mandatory, PluginFeature -> FeatureSupport.Optional @@ -340,7 +340,7 @@ object TestConstants { Features.StaticRemoteKey -> FeatureSupport.Mandatory, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, Features.Quiescence -> FeatureSupport.Optional, - Features.SplicePrototype -> FeatureSupport.Optional, + Features.Splicing -> FeatureSupport.Optional, Features.ChannelType -> FeatureSupport.Mandatory ), pluginParams = Nil, 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 331e089b45..50e79fd111 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 @@ -273,7 +273,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) - .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableSplice))(_.removed(Features.SplicePrototype)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableSplice))(_.removed(Features.Splicing)) .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)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/channel/GossipIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/channel/GossipIntegrationSpec.scala index 24bcd32ebe..1eb6eab12c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/channel/GossipIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/channel/GossipIntegrationSpec.scala @@ -25,9 +25,7 @@ class GossipIntegrationSpec extends FixtureSpec with IntegrationPatience { override def createFixture(testData: TestData): FixtureParam = { // seeds have been chosen so that node ids start with 02aaaa for alice, 02bbbb for bob, etc. val aliceParams = nodeParamsFor("alice", ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3")) - .modify(_.features).using(_.add(Features.SplicePrototype, FeatureSupport.Optional)) val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492")) - .modify(_.features).using(_.add(Features.SplicePrototype, FeatureSupport.Optional)) val carolParams = nodeParamsFor("carol", ByteVector32(hex"ebd5a5d3abfb3ef73731eb3418d918f247445183180522674666db98a66411cc")) ThreeNodesFixture(aliceParams, bobParams, carolParams, testData.name) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala index bb050bf95c..dafd75ead1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala @@ -118,7 +118,7 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory test("add liquidity if on-the-fly funding is used", Tag(noPlugin)) { f => import f._ - val features = defaultFeatures.add(Features.SplicePrototype, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) + val features = defaultFeatures.add(Features.Splicing, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) val requestFunding = LiquidityAds.RequestFunding(250_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(randomBytes32() :: Nil)) val open = createOpenDualFundedChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Some(requestFunding)).copy( channelFlags = ChannelFlags(nonInitiatorPaysCommitFees = true, announceChannel = false), @@ -210,7 +210,7 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory test("reject on-the-fly channel if another channel exists", Tag(noPlugin)) { f => import f._ - val features = defaultFeatures.add(Features.SplicePrototype, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) + val features = defaultFeatures.add(Features.Splicing, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) val requestFunding = LiquidityAds.RequestFunding(250_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(randomBytes32() :: Nil)) val open = createOpenDualFundedChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Some(requestFunding)).copy( channelFlags = ChannelFlags(nonInitiatorPaysCommitFees = true, announceChannel = false), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala index 598bc1ef06..cc7904d6e8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala @@ -19,11 +19,12 @@ package fr.acinq.eclair.io import akka.actor.PoisonPill import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, OutPoint, SatoshiLong, Transaction, TxId} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.TestConstants._ import fr.acinq.eclair.TestUtils.randomTxId +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.crypto.TransportHandler import fr.acinq.eclair.io.Peer.ConnectionDown @@ -155,7 +156,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi transport.send(peerConnection, LightningMessageCodecs.initCodec.decode(hex"0000 00050100000000".bits).require.value) transport.expectMsgType[TransportHandler.ReadAck] probe.expectTerminated(transport.ref) - origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,var_onion_optin)")) + origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,option_static_remotekey)")) peer.expectMsg(ConnectionDown(peerConnection)) } @@ -172,7 +173,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi transport.send(peerConnection, LightningMessageCodecs.initCodec.decode(hex"00050100000000 0000".bits).require.value) transport.expectMsgType[TransportHandler.ReadAck] probe.expectTerminated(transport.ref) - origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,var_onion_optin)")) + origin.expectMsg(PeerConnection.ConnectionResult.InitializationFailed("incompatible features (unknown_32,option_static_remotekey)")) peer.expectMsg(ConnectionDown(peerConnection)) } @@ -723,5 +724,53 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi } } + test("convert experimental splice messages") { f => + import f._ + val remoteInit = protocol.Init(Bob.nodeParams.features.initFeatures().add(Features.SplicePrototype, FeatureSupport.Optional)) + connect(nodeParams, remoteNodeId, switchboard, router, connection, transport, peerConnection, peer, remoteInit) + + val spliceInit = SpliceInit(randomBytes32(), 100_000 sat, FeeratePerKw(5000 sat), 0, randomKey().publicKey) + val spliceAck = SpliceAck(randomBytes32(), 50_000 sat, randomKey().publicKey) + val spliceLocked = SpliceLocked(randomBytes32(), TxId(randomBytes32())) + + // Outgoing messages use the experimental version of splicing. + peer.send(peerConnection, spliceInit) + transport.expectMsg(ExperimentalSpliceInit.from(spliceInit)) + peer.send(peerConnection, spliceAck) + transport.expectMsg(ExperimentalSpliceAck.from(spliceAck)) + peer.send(peerConnection, spliceLocked) + transport.expectMsg(ExperimentalSpliceLocked.from(spliceLocked)) + + // Incoming messages are converted from their experimental version. + transport.send(peerConnection, ExperimentalSpliceInit.from(spliceInit)) + peer.expectMsg(spliceInit) + transport.expectMsgType[TransportHandler.ReadAck] + transport.send(peerConnection, ExperimentalSpliceAck.from(spliceAck)) + peer.expectMsg(spliceAck) + transport.expectMsgType[TransportHandler.ReadAck] + transport.send(peerConnection, ExperimentalSpliceLocked.from(spliceLocked)) + peer.expectMsg(spliceLocked) + transport.expectMsgType[TransportHandler.ReadAck] + + // Incompatible TLVs are dropped when sending messages to peers using the experimental version. + val txAddInput = TxAddInput(randomBytes32(), UInt64(0), OutPoint(TxId(randomBytes32()), 3), 0) + assert(txAddInput.tlvStream.get[TxAddInputTlv.SharedInputTxId].nonEmpty) + peer.send(peerConnection, txAddInput) + assert(transport.expectMsgType[TxAddInput].tlvStream.get[TxAddInputTlv.SharedInputTxId].isEmpty) + val txSignatures = TxSignatures(randomBytes32(), Transaction(2, Nil, Nil, 0), Nil, Some(IndividualSignature(randomBytes64()))) + assert(txSignatures.tlvStream.get[TxSignaturesTlv.PreviousFundingTxSig].nonEmpty) + peer.send(peerConnection, txSignatures) + assert(transport.expectMsgType[TxSignatures].tlvStream.get[TxSignaturesTlv.PreviousFundingTxSig].isEmpty) + val channelId = randomBytes32() + val commitSigBatch = CommitSigBatch(Seq( + CommitSig(channelId, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.FundingTx(TxId(randomBytes32())), CommitSigTlv.ExperimentalBatchTlv(2))), + CommitSig(channelId, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.FundingTx(TxId(randomBytes32())), CommitSigTlv.ExperimentalBatchTlv(2))), + )) + peer.send(peerConnection, commitSigBatch) + assert(transport.expectMsgType[CommitSig].tlvStream.get[CommitSigTlv.FundingTx].isEmpty) + assert(transport.expectMsgType[CommitSig].tlvStream.get[CommitSigTlv.FundingTx].isEmpty) + transport.expectNoMessage(100 millis) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala index b70f6f5d17..b523f57d5d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/OnTheFlyFundingSpec.scala @@ -51,13 +51,13 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { Features.StaticRemoteKey -> FeatureSupport.Optional, Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional, Features.DualFunding -> FeatureSupport.Optional, - Features.SplicePrototype -> FeatureSupport.Optional, + Features.Splicing -> FeatureSupport.Optional, Features.OnTheFlyFunding -> FeatureSupport.Optional, ) val remoteFeaturesWithFeeCredit = Features( Features.DualFunding -> FeatureSupport.Optional, - Features.SplicePrototype -> FeatureSupport.Optional, + Features.Splicing -> FeatureSupport.Optional, Features.OnTheFlyFunding -> FeatureSupport.Optional, Features.FundingFeeCredit -> FeatureSupport.Optional, ) @@ -183,7 +183,7 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val nodeParams = TestConstants.Alice.nodeParams .modify(_.features.activated).using(_ + (Features.AnchorOutputsZeroFeeHtlcTx -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.DualFunding -> FeatureSupport.Optional)) - .modify(_.features.activated).using(_ + (Features.SplicePrototype -> FeatureSupport.Optional)) + .modify(_.features.activated).using(_ + (Features.Splicing -> FeatureSupport.Optional)) .modify(_.features.activated).using(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) .modify(_.features.activated).usingIf(test.tags.contains(withFeeCredit))(_ + (Features.FundingFeeCredit -> FeatureSupport.Optional)) val remoteNodeId = randomKey().publicKey diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index 88ed2d15af..2558de4126 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -222,7 +222,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { TxAddInput(channelId1, UInt64(561), Some(tx1), 1, 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005", TxAddInput(channelId2, UInt64(0), Some(tx2), 2, 0) -> hex"0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000", TxAddInput(channelId1, UInt64(561), Some(tx1), 0, 0) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 00000000", - TxAddInput(channelId1, UInt64(561), OutPoint(tx1, 1), 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106", + TxAddInput(channelId1, UInt64(561), OutPoint(tx1, 1), 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 00201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106", TxAddInput(channelId1, UInt64(561), None, 1, 0xfffffffdL, TlvStream(TxAddInputTlv.PrevTxOut(tx2.txid, 22_549_834 sat, hex"00148d2e0b57adcb8869e603fd35b5179caf05336125"))) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 fffffffd fd04573efc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1000000000158154a00148d2e0b57adcb8869e603fd35b5179caf05336125", TxAddOutput(channelId1, UInt64(1105), 2047 sat, hex"00149357014afd0ccd265658c9ae81efa995e771f472") -> hex"0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472", TxAddOutput(channelId1, UInt64(1105), 2047 sat, hex"00149357014afd0ccd265658c9ae81efa995e771f472", TlvStream(Set.empty[TxAddOutputTlv], Set(GenericTlv(UInt64(301), hex"2a")))) -> hex"0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472 fd012d012a", @@ -234,7 +234,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { TxComplete(channelId1, TlvStream(Set.empty[TxCompleteTlv], Set(GenericTlv(UInt64(231), hex"deadbeef"), GenericTlv(UInt64(507), hex"")))) -> hex"0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa e704deadbeef fd01fb00", TxSignatures(channelId1, tx2, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87")), ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None) -> hex"0047 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa fc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1 0002 004a 022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87 006b 0247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484", TxSignatures(channelId2, tx1, Nil, None) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000", - TxSignatures(channelId2, tx1, Nil, Some(IndividualSignature(signature))) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 fd0259 40 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + TxSignatures(channelId2, tx1, Nil, Some(IndividualSignature(signature))) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 0040aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb fd025940aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", TxSignatures(channelId2, tx1, Nil, Some(PartialSignatureWithNonce(partialSig, fundingNonce))) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 02 62 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb a49ff67b08c720b993c946556cde1be1c3b664bc847c4792135dfd6ef0986e00e9871808c6620b0420567dad525b27431453d4434fd326f8ac56496639b72326eb5d", TxInitRbf(channelId1, 8388607, FeeratePerKw(4000 sat)) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 007fffff 00000fa0", TxInitRbf(channelId1, 0, FeeratePerKw(4000 sat), 1_500_000 sat, requireConfirmedInputs = true, None) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 0008000000000016e360 0200", @@ -414,29 +414,30 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val channelId = ByteVector32(hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") val fundingTxId = TxId(TxHash(ByteVector32(hex"24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566"))) val fundingPubkey = PublicKey(hex"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") - val fundingRate = LiquidityAds.FundingRate(100_000.sat, 100_000.sat, 400, 150, 0.sat, 0.sat) + val fundingRate = LiquidityAds.FundingRate(100_000 sat, 100_000 sat, 400, 150, 0 sat, 0 sat) val testCases = Seq( // @formatter:off - SpliceInit(channelId, 100_000 sat, FeeratePerKw(2500 sat), 100, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceInit(channelId, 150_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 25_000_000 msat, requireConfirmedInputs = false, None) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000249f0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000704017d7840", - SpliceInit(channelId, 0 sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.RequestFunding(100_000 sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance))) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000", - SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, None, Some(SimpleTaprootChannelsPhoenix)) -> hex"9088 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c400000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe47000011 47 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000", - SpliceAck(channelId, 25_000 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None, None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", - SpliceAck(channelId, 0 sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, (-25_000).sat, fundingPubkey) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes)), None) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200", - SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729 msat))) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1", - SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, None, None, Some(SimpleTaprootChannelsPhoenix)) -> hex"908a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe47000011 47 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000", SpliceLocked(channelId, fundingTxId) -> hex"908c aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566", + SpliceInit(channelId, 100_000 sat, FeeratePerKw(2500 sat), 100, fundingPubkey) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceInit(channelId, 150_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 25_000_000 msat, requireConfirmedInputs = false, None) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000249f0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000704017d7840", + SpliceInit(channelId, 0 sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceInit(channelId, (-50_000).sat, FeeratePerKw(500 sat), 0, fundingPubkey) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff3cb0 000001f4 00000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.RequestFunding(100_000 sat, fundingRate, LiquidityAds.PaymentDetails.FromChannelBalance))) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c4 00000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b1e00000000000186a0000186a0000186a00190009600000000000000000000", + SpliceInit(channelId, 100_000 sat, 100, FeeratePerKw(2500 sat), fundingPubkey, 0 msat, requireConfirmedInputs = false, None, Some(SimpleTaprootChannelsPhoenix)) -> hex"0050 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000186a0 000009c400000064 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe47000011 47 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceAck(channelId, 40_000 sat, fundingPubkey, 10_000_000 msat, requireConfirmedInputs = false, None, None) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000009c40 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe4700000703989680", + SpliceAck(channelId, 0 sat, fundingPubkey) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000000 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceAck(channelId, (-25_000).sat, fundingPubkey) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ffffffffffff9e58 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, Some(LiquidityAds.WillFund(fundingRate, hex"deadbeef", ByteVector64.Zeroes)), None) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fd053b5a000186a0000186a00190009600000000000000000004deadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(0 msat))) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda05200", + SpliceAck(channelId, 25_000 sat, fundingPubkey, TlvStream(ChannelTlv.FeeCreditUsedTlv(1729 msat))) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fda0520206c1", + SpliceAck(channelId, 25_000 sat, fundingPubkey, 0 msat, requireConfirmedInputs = false, None, None, Some(SimpleTaprootChannelsPhoenix)) -> hex"0051 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000000061a8 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 fe47000011 47 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000", + SpliceLocked(channelId, fundingTxId) -> hex"004d aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 24e1b2c94c4e734dd5b9c5f3c910fbb6b3b436ced6382c7186056a5a23f14566", // @formatter:on ) - testCases.foreach { case (message, bin) => + testCases.foreach { case (msg, bin) => val decoded = lightningMessageCodec.decode(bin.bits).require.value - assert(decoded == message) - val encoded = lightningMessageCodec.encode(message).require.bytes + assert(decoded == msg) + val encoded = lightningMessageCodec.encode(msg).require.bytes assert(encoded == bin) } } From 93dab908fd4f8eaaa8404fbdaa15b43931b2ab52 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 4 Mar 2026 11:11:00 +0100 Subject: [PATCH 2/4] Re-work retransmission on reestablish We introduce a `retransmit_flags` field to `my_current_funding_locked` and `next_funding` to ask our peer to retransmit `commitment_signed` or `announcement_signatures` if we're expecting them. With this change, we don't need to retransmit `splice_locked` on reconnection anymore to trigger the exchange of `announcement_signatures`. We don't need to retransmit it to let our peer know that we've seen enough confirmations for the splice either, since `my_current_funding_locked` implies that. This allows us to completely remove retransmission of `splice_locked` on reconnection, and also get rid of the `your_last_funding_locked` TLV, which greatly simplifies the reconnection logic. We do keep them for backwards-compatibility with existing Phoenix users though, but we'll be able to clean it up once they have updated. Note that this works with taproot channels since we will simply provide nonces in `channel_reestablish` when we need our peer to send announcement signatures (not supported yet since taproot channels are never announced). We rollback using the `next_commitment_number` to let our peer know that we haven't received their `commit_sig` and instead use the retransmit flags added to the `next_funding` TLV, unless our peer is using the legacy splicing protocol. --- .../fr/acinq/eclair/channel/Commitments.scala | 2 + .../fr/acinq/eclair/channel/fsm/Channel.scala | 194 ++++++--- .../channel/fund/InteractiveTxBuilder.scala | 12 +- .../eclair/wire/protocol/ChannelTlv.scala | 77 +++- .../wire/protocol/LightningMessageTypes.scala | 14 +- .../fr/acinq/eclair/channel/RestoreSpec.scala | 5 +- .../b/WaitForDualFundingSignedStateSpec.scala | 18 +- ...WaitForDualFundingConfirmedStateSpec.scala | 15 +- .../c/WaitForDualFundingReadyStateSpec.scala | 10 +- .../states/e/NormalSplicesStateSpec.scala | 397 ++++++++++++------ .../channel/states/e/OfflineStateSpec.scala | 49 ++- .../protocol/LightningMessageCodecsSpec.scala | 94 ++++- 12 files changed, 632 insertions(+), 255 deletions(-) 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 dd18fca801..5a630f50df 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 @@ -33,6 +33,8 @@ case class ChannelParams(channelId: ByteVector32, val remoteNodeId: PublicKey = remoteParams.nodeId // If we've set the 0-conf feature bit for this peer, we will always use 0-conf with them. val zeroConf: Boolean = localParams.initFeatures.hasFeature(Features.ZeroConf) + // TODO: we keep supporting the legacy splicing protocol for non-upgraded Phoenix users. + lazy val useLegacySpliceProtocol = remoteParams.initFeatures.hasFeature(Features.SplicePrototype) /** We update local/global features at reconnection. */ def updateFeatures(localInit: Init, remoteInit: Init): ChannelParams = copy( 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 29cfe9ee47..aefb30b00c 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 @@ -247,6 +247,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // we record the announcement_signatures messages we already sent to avoid unnecessary retransmission var announcementSigsSent = Set.empty[RealShortChannelId] // we keep track of the splice_locked we sent after channel_reestablish and it's funding tx index to avoid sending it again + // TODO: we can remove that once we stop supporting the legacy splicing protocol private var spliceLockedSent = Map.empty[TxId, Long] private def trimAnnouncementSigsStashIfNeeded(): Unit = { @@ -1519,18 +1520,22 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(msg: SpliceLocked, d: DATA_NORMAL) => d.commitments.updateRemoteFundingStatus(msg.fundingTxId, d.lastAnnouncedFundingTxId_opt) match { case Right((commitments1, commitment)) => - // If we have both already sent splice_locked for this commitment, then we are receiving splice_locked - // again after a reconnection and must retransmit our splice_locked and new announcement_signatures. Nodes - // retransmit splice_locked after a reconnection when they have received splice_locked but NOT matching signatures - // before the last disconnect. If a matching splice_locked has already been sent since reconnecting, then do not - // retransmit splice_locked to avoid a loop. - // NB: It is important both nodes retransmit splice_locked after reconnecting to ensure new Taproot nonces - // are exchanged for channel announcements. - val isLatestLocked = d.commitments.lastLocalLocked_opt.exists(_.fundingTxId == msg.fundingTxId) && d.commitments.lastRemoteLocked_opt.exists(_.fundingTxId == msg.fundingTxId) - val spliceLocked_opt = if (d.commitments.announceChannel && isLatestLocked && !spliceLockedSent.contains(commitment.fundingTxId)) { - spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex) - trimSpliceLockedSentIfNeeded() - Some(SpliceLocked(d.channelId, commitment.fundingTxId)) + val spliceLocked_opt = if (d.channelParams.useLegacySpliceProtocol) { + // If we have both already sent splice_locked for this commitment, then we are receiving splice_locked + // again after a reconnection and must retransmit our splice_locked and new announcement_signatures. Nodes + // retransmit splice_locked after a reconnection when they have received splice_locked but NOT matching signatures + // before the last disconnect. If a matching splice_locked has already been sent since reconnecting, then do not + // retransmit splice_locked to avoid a loop. + // NB: It is important both nodes retransmit splice_locked after reconnecting to ensure new Taproot nonces + // are exchanged for channel announcements. + val isLatestLocked = d.commitments.lastLocalLocked_opt.exists(_.fundingTxId == msg.fundingTxId) && d.commitments.lastRemoteLocked_opt.exists(_.fundingTxId == msg.fundingTxId) + if (d.commitments.announceChannel && isLatestLocked && !spliceLockedSent.contains(commitment.fundingTxId)) { + spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex) + trimSpliceLockedSentIfNeeded() + Some(SpliceLocked(d.channelId, commitment.fundingTxId)) + } else { + None + } } else { None } @@ -2412,7 +2417,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(INPUT_RECONNECTED(r, localInit, remoteInit), d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) => activeConnection = r val myFirstPerCommitmentPoint = channelKeys.commitmentPoint(0) - val nextFundingTlv: Set[ChannelReestablishTlv] = Set(ChannelReestablishTlv.NextFundingTlv(d.signingSession.fundingTxId)) + val nextFundingTlv = if (d.channelParams.useLegacySpliceProtocol) { + Set[ChannelReestablishTlv](ChannelReestablishTlv.ExperimentalNextFundingTlv(d.signingSession.fundingTxId)) + } else { + Set[ChannelReestablishTlv](ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(d.signingSession.fundingTxId, d.signingSession.retransmitRemoteCommitSig)) + } val nonceTlvs = d.signingSession.fundingParams.commitmentFormat match { case _: SegwitV0CommitmentFormat => Set.empty case _: SimpleTaprootChannelCommitmentFormat => @@ -2430,7 +2439,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } val channelReestablish = ChannelReestablish( channelId = d.channelId, - nextLocalCommitmentNumber = d.signingSession.nextLocalCommitmentNumber, + nextLocalCommitmentNumber = d.signingSession.nextLocalCommitmentNumber(d.channelParams.useLegacySpliceProtocol), nextRemoteRevocationNumber = 0, yourLastPerCommitmentSecret = PrivateKey(ByteVector32.Zeroes), myCurrentPerCommitmentPoint = myFirstPerCommitmentPoint, @@ -2444,41 +2453,72 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val remotePerCommitmentSecrets = d.commitments.remotePerCommitmentSecrets val yourLastPerCommitmentSecret = remotePerCommitmentSecrets.lastIndex.flatMap(remotePerCommitmentSecrets.getHash).getOrElse(ByteVector32.Zeroes) val myCurrentPerCommitmentPoint = channelKeys.commitmentPoint(d.commitments.localCommitIndex) - // If we disconnected while signing a funding transaction, we may need our peer to retransmit their commit_sig. + // TODO: replace by d.commitments.localCommitIndex + 1 when removing support for the legacy splice protocol. val nextLocalCommitmentNumber = d match { case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { - case DualFundingStatus.RbfWaitingForSigs(status) => status.nextLocalCommitmentNumber + case DualFundingStatus.RbfWaitingForSigs(status) => status.nextLocalCommitmentNumber(d.channelParams.useLegacySpliceProtocol) case _ => d.commitments.localCommitIndex + 1 } case d: DATA_NORMAL => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(status) => status.nextLocalCommitmentNumber + case SpliceStatus.SpliceWaitingForSigs(status) => status.nextLocalCommitmentNumber(d.channelParams.useLegacySpliceProtocol) case _ => d.commitments.localCommitIndex + 1 } case _ => d.commitments.localCommitIndex + 1 } - // If we disconnected while signing a funding transaction, we may need our peer to (re)transmit their tx_signatures. - val rbfTlv: Set[ChannelReestablishTlv] = d match { - case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { - case DualFundingStatus.RbfWaitingForSigs(status) => Set(ChannelReestablishTlv.NextFundingTlv(status.fundingTx.txId)) - case _ => d.latestFundingTx.sharedTx match { - case _: InteractiveTxBuilder.PartiallySignedSharedTransaction => Set(ChannelReestablishTlv.NextFundingTlv(d.latestFundingTx.sharedTx.txId)) - case _: InteractiveTxBuilder.FullySignedSharedTransaction => Set.empty + // If we disconnected while signing a funding transaction, we may need our peer to (re)transmit their tx_signatures and commit_sig. + val rbfTlv: Set[ChannelReestablishTlv] = if (d.channelParams.useLegacySpliceProtocol) { + d match { + case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { + case DualFundingStatus.RbfWaitingForSigs(status) => Set(ChannelReestablishTlv.ExperimentalNextFundingTlv(status.fundingTx.txId)) + case _ => d.latestFundingTx.sharedTx match { + case _: InteractiveTxBuilder.PartiallySignedSharedTransaction => Set(ChannelReestablishTlv.ExperimentalNextFundingTlv(d.latestFundingTx.sharedTx.txId)) + case _: InteractiveTxBuilder.FullySignedSharedTransaction => Set.empty + } + } + case d: DATA_NORMAL => d.spliceStatus match { + case SpliceStatus.SpliceWaitingForSigs(status) => Set(ChannelReestablishTlv.ExperimentalNextFundingTlv(status.fundingTx.txId)) + case _ => d.commitments.latest.localFundingStatus match { + case LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) => Set(ChannelReestablishTlv.ExperimentalNextFundingTlv(fundingTx.txId)) + case _ => Set.empty + } } + case _ => Set.empty } - case d: DATA_NORMAL => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(status) => Set(ChannelReestablishTlv.NextFundingTlv(status.fundingTx.txId)) - case _ => d.commitments.latest.localFundingStatus match { - case LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) => Set(ChannelReestablishTlv.NextFundingTlv(fundingTx.txId)) - case _ => Set.empty + } else { + d match { + case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { + case DualFundingStatus.RbfWaitingForSigs(status) => Set(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(status.fundingTx.txId, status.retransmitRemoteCommitSig)) + case _ => d.latestFundingTx.sharedTx match { + case _: InteractiveTxBuilder.PartiallySignedSharedTransaction => Set(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(d.latestFundingTx.sharedTx.txId, retransmitCommitSig = false)) + case _: InteractiveTxBuilder.FullySignedSharedTransaction => Set.empty + } } + case d: DATA_NORMAL => d.spliceStatus match { + case SpliceStatus.SpliceWaitingForSigs(status) => Set(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(status.fundingTx.txId, status.retransmitRemoteCommitSig)) + case _ => d.commitments.latest.localFundingStatus match { + case LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) => Set(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(fundingTx.txId, retransmitCommitSig = false)) + case _ => Set.empty + } + } + case _ => Set.empty } - case _ => Set.empty } - val remoteFeatures = d.commitments.remoteChannelParams.initFeatures - val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.hasFeature(Features.SplicePrototype)) { - d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++ - d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet - } else Set.empty + val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (d.channelParams.useLegacySpliceProtocol) { + val myCurrentFundingLocked_opt = d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.ExperimentalMyCurrentFundingLockedTlv(c.fundingTxId)) + val yourLastFundingLocked_opt = d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asExperimentalYourLastFundingLocked(c.fundingTxId)) + myCurrentFundingLocked_opt.toSet ++ yourLastFundingLocked_opt.toSet + } else if (d.channelParams.remoteParams.initFeatures.hasFeature(Features.Splicing)) { + d.commitments.lastLocalLocked_opt.map(c => { + // We ask our peer to retransmit their announcement_signatures if we haven't already announced that splice. + val retransmitAnnSigs = d match { + case d: DATA_NORMAL if d.commitments.announceChannel => !d.lastAnnouncedFundingTxId_opt.contains(c.fundingTxId) + case _ => false + } + ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId, retransmitAnnSigs) + }).toSet + } else { + Set.empty + } // We send our verification nonces for all active commitments. val nextCommitNonces: Map[TxId, IndividualNonce] = d.commitments.active.flatMap(c => { @@ -2563,8 +2603,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall handleLocalError(f, d, Some(channelReestablish)) case _ => remoteNextCommitNonces = channelReestablish.nextCommitNonces + val retransmitCommitSig = if (d.channelParams.useLegacySpliceProtocol) { + channelReestablish.nextLocalCommitmentNumber == 0 + } else { + channelReestablish.retransmitInteractiveTxCommitSig + } channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) if fundingTxId == d.signingSession.fundingTx.txId && channelReestablish.nextLocalCommitmentNumber == 0 => + case Some(fundingTxId) if fundingTxId == d.signingSession.fundingTx.txId && retransmitCommitSig => // They haven't received our commit_sig: we retransmit it, and will send our tx_signatures once we've received // their commit_sig or their tx_signatures (depending on who must send tx_signatures first). val fundingParams = d.signingSession.fundingParams @@ -2588,11 +2633,16 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces + val retransmitCommitSig = if (d.channelParams.useLegacySpliceProtocol) { + channelReestablish.nextLocalCommitmentNumber == 0 + } else { + channelReestablish.retransmitInteractiveTxCommitSig + } channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) => d.status match { case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => - if (channelReestablish.nextLocalCommitmentNumber == 0) { + if (retransmitCommitSig) { // They haven't received our commit_sig: we retransmit it. // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. val fundingParams = signingSession.fundingParams @@ -2609,7 +2659,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case _ if d.latestFundingTx.sharedTx.txId == fundingTxId => // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures // and our commit_sig if they haven't received it already. - if (channelReestablish.nextLocalCommitmentNumber == 0) { + if (retransmitCommitSig) { val remoteNonce_opt = channelReestablish.currentCommitNonce_opt d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { case Left(e) => handleLocalError(e, d, Some(channelReestablish)) @@ -2644,10 +2694,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val channelReady = createChannelReady(d.aliases, d.commitments) // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures // and our commit_sig if they haven't received it already. + val retransmitCommitSig = if (d.channelParams.useLegacySpliceProtocol) { + channelReestablish.nextLocalCommitmentNumber == 0 + } else { + channelReestablish.retransmitInteractiveTxCommitSig + } channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) if fundingTxId == d.commitments.latest.fundingTxId => d.commitments.latest.localFundingStatus.localSigs_opt match { - case Some(txSigs) if channelReestablish.nextLocalCommitmentNumber == 0 => + case Some(txSigs) if retransmitCommitSig => log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) val remoteNonce_opt = channelReestablish.currentCommitNonce_opt d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { @@ -2700,15 +2755,16 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces // We re-send our latest splice_locked if needed. - val spliceLocked_opt = resendSpliceLockedIfNeeded(channelReestablish, commitments1, d.lastAnnouncement_opt) - sendQueue = sendQueue ++ spliceLocked_opt.toSeq + val spliceLocked_opt = resendSpliceLockedIfNeeded(commitments1) + // We retransmit our latest announcement_signatures if our peer requests it. + val spliceAnnSigs_opt = resendSpliceAnnSigsIfNeeded(channelReestablish, commitments1) + sendQueue = sendQueue ++ spliceLocked_opt.toSeq ++ spliceAnnSigs_opt.toSeq // We may need to retransmit updates and/or commit_sig and/or revocation to resume the channel. sendQueue = sendQueue ++ syncSuccess.retransmit commitments1.remoteNextCommitInfo match { - case Left(_) => - // we expect them to (re-)send the revocation immediately - startSingleTimer(RevocationTimeout.toString, RevocationTimeout(commitments1.remoteCommitIndex, peer), nodeParams.channelConf.revocationTimeout) + // we expect them to (re-)send their revocation immediately + case Left(_) => startSingleTimer(RevocationTimeout.toString, RevocationTimeout(commitments1.remoteCommitIndex, peer), nodeParams.channelConf.revocationTimeout) case _ => () } @@ -3399,23 +3455,20 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // We only send channel_ready for initial funding transactions. case Some(c) if c.fundingTxIndex != 0 => (None, None) case Some(c) => - val remoteFeatures = d.commitments.remoteChannelParams.initFeatures - val remoteSpliceSupport = remoteFeatures.hasFeature(Features.Splicing) || remoteFeatures.hasFeature(Features.SplicePrototype) - // If our peer has not received our channel_ready, we retransmit it. - val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node // MUST retransmit channel_ready, otherwise it MUST NOT - val notReceivedByRemoteLegacy = !remoteSpliceSupport && channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 + val notReceivedByRemote = channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and // will also send announcement_signatures. val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty - val channelReady_opt = if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { + // If our peer is a phoenix wallet using the legacy splicing protocol, we always retransmit channel_ready. + val channelReady_opt = if (notAnnouncedYet || notReceivedByRemote || d.channelParams.useLegacySpliceProtocol) { log.debug("re-sending channel_ready") Some(createChannelReady(d.aliases, d.commitments)) } else { None } - val announcementSigs_opt = if (notAnnouncedYet) { + val announcementSigs_opt = if (notAnnouncedYet || (channelReestablish.retransmitAnnSigs && channelReestablish.myCurrentFundingLocked_opt.contains(c.fundingTxId))) { // The funding transaction is confirmed, so we've already sent our announcement_signatures. // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. @@ -3431,11 +3484,16 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall private def resumeSpliceSigningSessionIfNeeded(channelReestablish: ChannelReestablish, d: DATA_NORMAL): (SpliceStatus, Queue[LightningMessage]) = { var sendQueue = Queue.empty[LightningMessage] + val retransmitCommitSig = if (d.channelParams.useLegacySpliceProtocol) { + channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex + } else { + channelReestablish.retransmitInteractiveTxCommitSig + } val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) => d.spliceStatus match { case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => - if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { + if (retransmitCommitSig) { // They haven't received our commit_sig: we retransmit it. // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. log.info("re-sending commit_sig for splice attempt with fundingTxIndex={} fundingTxId={}", signingSession.fundingTxIndex, signingSession.fundingTx.txId) @@ -3452,7 +3510,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case dfu: LocalFundingStatus.DualFundedUnconfirmedFundingTx => // We've already received their commit_sig and sent our tx_signatures. We retransmit our // tx_signatures and our commit_sig if they haven't received it already. - if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { + if (retransmitCommitSig) { log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) val remoteNonce_opt = channelReestablish.currentCommitNonce_opt d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { @@ -3481,23 +3539,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall (spliceStatus1, sendQueue) } - private def resendSpliceLockedIfNeeded(channelReestablish: ChannelReestablish, commitments: Commitments, lastAnnouncement_opt: Option[ChannelAnnouncement]): Option[SpliceLocked] = { + private def resendSpliceLockedIfNeeded(commitments: Commitments): Option[SpliceLocked] = { commitments.lastLocalLocked_opt match { case None => None // We only send splice_locked for splice transactions. case Some(c) if c.fundingTxIndex == 0 => None case Some(c) => - // If our peer has not received our splice_locked, we retransmit it. - val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) - // If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and - // will exchange announcement_signatures afterwards. - val notAnnouncedYet = commitments.announceChannel && lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId)) - if (notReceivedByRemote || notAnnouncedYet) { - // Retransmission of local announcement_signatures for splices are done when receiving splice_locked, no need - // to retransmit here. + // We only send splice_locked for legacy phoenix wallets using the old splicing protocol. + if (commitments.channelParams.useLegacySpliceProtocol) { log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) - spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) - trimSpliceLockedSentIfNeeded() Some(SpliceLocked(commitments.channelId, c.fundingTxId)) } else { None @@ -3505,6 +3555,22 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } + private def resendSpliceAnnSigsIfNeeded(channelReestablish: ChannelReestablish, commitments: Commitments): Option[AnnouncementSignatures] = { + commitments.lastLocalLocked_opt match { + case None => None + // This retransmit mechanism is only available for splice transactions. + case Some(c) if c.fundingTxIndex == 0 => None + case Some(c) if channelReestablish.retransmitAnnSigs && commitments.announceChannel && channelReestablish.myCurrentFundingLocked_opt.contains(c.fundingTxId) => + val localAnnSigs = c.signAnnouncement(nodeParams, commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) + localAnnSigs.foreach(annSigs => { + log.debug("re-sending announcement_signatures for fundingTxId={}", c.fundingTxId) + announcementSigsSent += annSigs.shortChannelId + }) + localAnnSigs + case _ => None + } + } + /** * Return full information about a known closing tx. */ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index 5edd4564d8..b054ecc980 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -1206,10 +1206,14 @@ object InteractiveTxSigningSession { liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends InteractiveTxSigningSession { val fundingTxId: TxId = fundingTx.txId val localCommitIndex: Long = localCommit.fold(_.index, _.index) - // This value tells our peer whether we need them to retransmit their commit_sig on reconnection or not. - val nextLocalCommitmentNumber: Long = localCommit match { - case Left(unsignedCommit) => unsignedCommit.index - case Right(commit) => commit.index + 1 + // If we haven't received the remote commit_sig, we will request a retransmission on reconnection. + val retransmitRemoteCommitSig: Boolean = localCommit.isLeft + + // For the legacy splice protocol, we use the next_commitment_number to let our peer know whether they needed to + // retransmit commit_sig or not. We're now using an explicit bit instead, but need to maintain backwards-compatibility. + def nextLocalCommitmentNumber(useLegacySpliceProtocol: Boolean): Long = localCommit match { + case Left(unsignedCommit) if useLegacySpliceProtocol => unsignedCommit.index + case _ => localCommitIndex + 1 } def localFundingKey(channelKeys: ChannelKeys): PrivateKey = channelKeys.fundingKey(fundingTxIndex) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index 9aad296841..6c6ec2ed13 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.wire.protocol import fr.acinq.bitcoin.scalacompat.Musig2.IndividualNonce -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, TxHash, TxId} import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce import fr.acinq.eclair.channel.{ChannelType, ChannelTypes} import fr.acinq.eclair.wire.protocol.CommonCodecs._ @@ -255,17 +255,48 @@ sealed trait ChannelReestablishTlv extends Tlv object ChannelReestablishTlv { + /** TODO: replaced by [[NextFundingTlv]], remove once Phoenix users have upgraded. */ + case class ExperimentalNextFundingTlv(txId: TxId) extends ChannelReestablishTlv + + /** + * We unfortunately have a conflict between the splicing protocol we use for Phoenix and the official one. + * The official protocol uses TLV = 1 for the next_funding TLV, which should be implemented by [[NextFundingTlv]] + * (which is commented out below). + * The splicing protocol used for Phoenix also uses TLV = 1, but for a different TLV that was removed from the + * official splicing protocol (your_last_funding_locked), which contained the txid of the last [[ChannelReady]] or + * [[SpliceLocked]] message received before disconnecting, if any. + * + * To guarantee backwards-compatibility, we create a TLV field that may contain both options. When using the official + * splicing protocol, it will contain 33 bytes (a txid and a bitfield), while when using the legacy protocol it only + * contains a txid, which lets us easily distinguish the two. + * + * TODO: once we can remove support for Phoenix users with the legacy splicing protocol, this should just be replaced + * by [[NextFundingTlv]] which is commented out below and should be uncommented. + */ + case class NextFundingOrExperimentalYourLastFundingLockedTlv(data: ByteVector) extends ChannelReestablishTlv { + val isOfficial: Boolean = data.size == 33 + val txId: TxId = TxId(TxHash(ByteVector32(data.take(32)))) + // NB: this is only used for the official splicing protocol. + val retransmitCommitSig: Boolean = if (isOfficial) (data.last.toInt % 2) == 1 else false + } + /** * When disconnected in the middle of an interactive-tx session, this field is used to request a retransmission of * [[TxSignatures]] for the given [[txId]]. + * + * @param txId the txid of the partially signed funding transaction. + * @param retransmitCommitSig true if [[CommitSig]] must be retransmitted before [[TxSignatures]]. */ - case class NextFundingTlv(txId: TxId) extends ChannelReestablishTlv + // case class NextFundingTlv(txId: TxId, retransmitCommitSig: Boolean) extends ChannelReestablishTlv - /** The txid of the last [[ChannelReady]] or [[SpliceLocked]] message received before disconnecting, if any. */ - case class YourLastFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv + /** TODO: replaced by [[MyCurrentFundingLockedTlv]], remove once Phoenix users have upgraded. */ + case class ExperimentalMyCurrentFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv - /** The txid of our latest outgoing [[ChannelReady]] or [[SpliceLocked]] for this channel. */ - case class MyCurrentFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv + /** + * @param txId the txid of our latest outgoing [[ChannelReady]] or [[SpliceLocked]] for this channel. + * @param retransmitAnnSigs true if [[AnnouncementSignatures]] must be retransmitted. + */ + case class MyCurrentFundingLockedTlv(txId: TxId, retransmitAnnSigs: Boolean) extends ChannelReestablishTlv /** * When disconnected during an interactive tx session, we'll include a verification nonce for our *current* commitment @@ -280,16 +311,31 @@ object ChannelReestablishTlv { */ case class NextLocalNoncesTlv(nonces: Seq[(TxId, IndividualNonce)]) extends ChannelReestablishTlv - object NextFundingTlv { - val codec: Codec[NextFundingTlv] = tlvField(txIdAsHash) + object ExperimentalNextFundingTlv { + val codec: Codec[ExperimentalNextFundingTlv] = tlvField(txIdAsHash) + } + + // object NextFundingTlv { + // val codec: Codec[NextFundingTlv] = tlvField(("next_funding_txid" | txIdAsHash) :: ("retransmit_flags" | (ignore(7) :: bool))) + // } + + object NextFundingOrExperimentalYourLastFundingLockedTlv { + def asNextFunding(txId: TxId, retransmitCommitSig: Boolean): NextFundingOrExperimentalYourLastFundingLockedTlv = { + val retransmitFlags = if (retransmitCommitSig) ByteVector.fromValidHex("01") else ByteVector.fromValidHex("00") + NextFundingOrExperimentalYourLastFundingLockedTlv(TxHash(txId).value ++ retransmitFlags) + } + + def asExperimentalYourLastFundingLocked(txId: TxId): NextFundingOrExperimentalYourLastFundingLockedTlv = NextFundingOrExperimentalYourLastFundingLockedTlv(TxHash(txId).value) + + val codec: Codec[NextFundingOrExperimentalYourLastFundingLockedTlv] = tlvField(bytes) } - object YourLastFundingLockedTlv { - val codec: Codec[YourLastFundingLockedTlv] = tlvField("your_last_funding_locked_txid" | txIdAsHash) + object ExperimentalMyCurrentFundingLockedTlv { + val codec: Codec[ExperimentalMyCurrentFundingLockedTlv] = tlvField("my_current_funding_locked_txid" | txIdAsHash) } object MyCurrentFundingLockedTlv { - val codec: Codec[MyCurrentFundingLockedTlv] = tlvField("my_current_funding_locked_txid" | txIdAsHash) + val codec: Codec[MyCurrentFundingLockedTlv] = tlvField(("my_current_funding_locked_txid" | txIdAsHash) :: ("retransmit_flags" | (ignore(7) :: bool))) } object CurrentCommitNonceTlv { @@ -301,9 +347,12 @@ object ChannelReestablishTlv { } val channelReestablishTlvCodec: Codec[TlvStream[ChannelReestablishTlv]] = tlvStream(discriminated[ChannelReestablishTlv].by(varint) - .typecase(UInt64(0), NextFundingTlv.codec) - .typecase(UInt64(1), YourLastFundingLockedTlv.codec) - .typecase(UInt64(3), MyCurrentFundingLockedTlv.codec) + .typecase(UInt64(0), ExperimentalNextFundingTlv.codec) + // TODO: replace with the commented line below when removing support for the legacy splicing protocol. + .typecase(UInt64(1), NextFundingOrExperimentalYourLastFundingLockedTlv.codec) + // .typecase(UInt64(1), NextFundingTlv.codec) + .typecase(UInt64(3), ExperimentalMyCurrentFundingLockedTlv.codec) + .typecase(UInt64(5), MyCurrentFundingLockedTlv.codec) .typecase(UInt64(22), NextLocalNoncesTlv.codec) .typecase(UInt64(24), CurrentCommitNonceTlv.codec) ) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 3867c62123..7980608798 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -212,9 +212,17 @@ case class ChannelReestablish(channelId: ByteVector32, yourLastPerCommitmentSecret: PrivateKey, myCurrentPerCommitmentPoint: PublicKey, tlvStream: TlvStream[ChannelReestablishTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { - val nextFundingTxId_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.NextFundingTlv].map(_.txId) - val myCurrentFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.MyCurrentFundingLockedTlv].map(_.txId) - val yourLastFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.YourLastFundingLockedTlv].map(_.txId) + val nextFundingTxId_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv] match { + case Some(tlv) if tlv.isOfficial => Some(tlv.txId) + case _ => tlvStream.get[ChannelReestablishTlv.ExperimentalNextFundingTlv].map(_.txId) + } + val retransmitInteractiveTxCommitSig: Boolean = tlvStream.get[ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv].exists(_.retransmitCommitSig) + val myCurrentFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.MyCurrentFundingLockedTlv].map(_.txId).orElse(tlvStream.get[ChannelReestablishTlv.ExperimentalMyCurrentFundingLockedTlv].map(_.txId)) + val yourLastFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv] match { + case Some(tlv) if !tlv.isOfficial => Some(tlv.txId) + case _ => None + } + val retransmitAnnSigs: Boolean = tlvStream.get[ChannelReestablishTlv.MyCurrentFundingLockedTlv].exists(_.retransmitAnnSigs) val nextCommitNonces: Map[TxId, IndividualNonce] = tlvStream.get[ChannelReestablishTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) val currentCommitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelReestablishTlv.CurrentCommitNonceTlv].map(_.nonce) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala index 1050912659..1db149c488 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala @@ -9,7 +9,7 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.wire.protocol.{ChannelReestablish, ChannelUpdate, Init} +import fr.acinq.eclair.wire.protocol.{ChannelReady, ChannelReestablish, ChannelUpdate, Init} import fr.acinq.eclair.{TestKitBaseClass, _} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -130,6 +130,9 @@ class RestoreSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Chan assert(cup.channelUpdate.feeProportionalMillionths == newConfig.relayParams.privateChannelFees.feeProportionalMillionths) assert(cup.channelUpdate.cltvExpiryDelta == newConfig.channelConf.expiryDelta) + alice2bob.ignoreMsg { case _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelReady => true } + newAlice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) alice2bob.expectMsgType[ChannelReestablish] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala index 7c2efb157c..7f546e02b7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala @@ -486,13 +486,15 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] - assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) assert(channelReestablishAlice.nextCommitNonces.contains(fundingTxId)) bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] - assert(channelReestablishBob.nextLocalCommitmentNumber == 0) + assert(channelReestablishBob.nextLocalCommitmentNumber == 1) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) assert(channelReestablishBob.nextCommitNonces.contains(fundingTxId)) @@ -591,12 +593,14 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(channelReestablishAlice.nextCommitNonces.contains(fundingTx.txid)) assert(channelReestablishAlice.nextCommitNonces.get(fundingTx.txid) != channelReestablishAlice.currentCommitNonce_opt) assert(channelReestablishAlice.nextFundingTxId_opt.contains(fundingTx.txid)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) alice2bob.forward(bob, channelReestablishAlice) val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) assert(channelReestablishBob.nextCommitNonces.get(fundingTx.txid) == channelReadyB.nextCommitNonce_opt) assert(channelReestablishBob.nextFundingTxId_opt.isEmpty) + assert(!channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) bob2alice.forward(alice, channelReestablishBob) @@ -734,14 +738,14 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] - val nextLocalCommitmentNumberAlice = if (aliceExpectsCommitSig) 0 else 1 assert(channelReestablishAlice.nextFundingTxId_opt.contains(fundingTxId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == nextLocalCommitmentNumberAlice) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig == aliceExpectsCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) alice2bob.forward(bob, channelReestablishAlice) val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] - val nextLocalCommitmentNumberBob = if (bobExpectsCommitSig) 0 else 1 assert(channelReestablishBob.nextFundingTxId_opt.contains(fundingTxId)) - assert(channelReestablishBob.nextLocalCommitmentNumber == nextLocalCommitmentNumberBob) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig == bobExpectsCommitSig) + assert(channelReestablishBob.nextLocalCommitmentNumber == 1) bob2alice.forward(alice, channelReestablishBob) // When using taproot, we must provide nonces for the partial signatures. 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..41ebb3b23c 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 @@ -960,8 +960,10 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.isEmpty) + assert(!channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) commitmentFormat match { case _: SegwitV0CommitmentFormat => () @@ -1008,9 +1010,11 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) + assert(!channelReestablishAlice.retransmitInteractiveTxCommitSig) assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishBob.nextLocalCommitmentNumber == 0) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob.nextLocalCommitmentNumber == 1) commitmentFormat match { case _: SegwitV0CommitmentFormat => () case _: TaprootCommitmentFormat => @@ -1069,8 +1073,10 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) + assert(!channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) commitmentFormat match { case _: SegwitV0CommitmentFormat => () @@ -1137,7 +1143,8 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] assert(channelReestablishAlice.nextFundingTxId_opt.nonEmpty) - assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) bob2alice.expectMsgType[ChannelReestablish] alice2bob.forward(bob, channelReestablishAlice.copy(tlvStream = TlvStream(channelReestablishAlice.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.CurrentCommitNonceTlv])))) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala index c12210112e..54c3a67668 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala @@ -238,18 +238,16 @@ class WaitForDualFundingReadyStateSpec extends TestKitBaseClass with FixtureAnyF val bobInit = Init(TestConstants.Bob.nodeParams.features.initFeatures()) alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) - alice2bob.expectMsgType[ChannelReestablish] + assert(alice2bob.expectMsgType[ChannelReestablish].retransmitAnnSigs) alice2bob.forward(bob) - bob2alice.expectMsgType[ChannelReestablish] + assert(!bob2alice.expectMsgType[ChannelReestablish].retransmitAnnSigs) bob2alice.forward(alice) - // Bob does not retransmit channel_ready and announcement_signatures because he has already received both of them from Alice. - bob2alice.expectNoMessage(100 millis) - // Alice has already received Bob's channel_ready, but not its announcement_signatures. - // She retransmits channel_ready and Bob will retransmit its announcement_signatures in response. alice2bob.expectMsgType[ChannelReady] alice2bob.forward(bob) alice2bob.expectMsgType[AnnouncementSignatures] alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReady] + bob2alice.forward(alice) bob2alice.expectMsgType[AnnouncementSignatures] bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.nonEmpty) 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 dfab1676eb..053d3929cc 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 @@ -275,6 +275,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.forall(c => c.fundingTxIndex > fundingTxIndex || c.fundingTxId == spliceTx.txid), interval = 100 millis) } + private def getFundingScid(f: FixtureParam, fundingTx: Transaction): Option[RealShortChannelId] = { + import f._ + + val aliceScid_opt = alice.commitments.all.find(_.fundingTxId == fundingTx.txid).flatMap(_.shortChannelId_opt) + val bobScid_opt = bob.commitments.all.find(_.fundingTxId == fundingTx.txid).flatMap(_.shortChannelId_opt) + aliceScid_opt.orElse(bobScid_opt) + } + case class TestHtlcs(aliceToBob: Seq[(ByteVector32, UpdateAddHtlc)], bobToAlice: Seq[(ByteVector32, UpdateAddHtlc)]) private def setupHtlcs(f: FixtureParam): TestHtlcs = { @@ -1507,9 +1515,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt)) assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx1.txid) bob2alice.forward(alice) - alice2bob.expectMsgType[AnnouncementSignatures] + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, spliceTx1).contains(ann.shortChannelId)) } alice2bob.forward(bob) val bobAnnSigs1 = bob2alice.expectMsgType[AnnouncementSignatures] // Alice doesn't receive Bob's signatures. + assert(getFundingScid(f, spliceTx1).contains(bobAnnSigs1.shortChannelId)) awaitAssert(assert(bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.exists(_ != ann))) val spliceAnn1 = bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.get assert(spliceAnn1.shortChannelId != ann.shortChannelId) @@ -1540,9 +1549,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput1.txid, fundingInput1.index.toInt)) assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx2.txid) bob2alice.forward(alice) - alice2bob.expectMsgType[AnnouncementSignatures] + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, spliceTx2).contains(ann.shortChannelId)) } alice2bob.forward(bob) val bobAnnSigs2 = bob2alice.expectMsgType[AnnouncementSignatures] // Alice doesn't receive Bob's signatures. + assert(getFundingScid(f, spliceTx2).contains(bobAnnSigs2.shortChannelId)) awaitAssert(assert(bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.exists(_ != spliceAnn1))) val spliceAnn2 = bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.get assert(spliceAnn2.shortChannelId != spliceAnn1.shortChannelId) @@ -1580,7 +1590,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice and Bob want to announce the initial funding transaction, but the messages are dropped. val shortChannelId = alice2bob.expectMsgType[AnnouncementSignatures].shortChannelId alice2bob.expectMsgType[ChannelUpdate] - bob2alice.expectMsgType[AnnouncementSignatures] + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(ann.shortChannelId == shortChannelId) } bob2alice.expectMsgType[ChannelUpdate] assert(alice.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.isEmpty) assert(bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.isEmpty) @@ -1602,9 +1612,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt)) assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid) bob2alice.forward(alice) - alice2bob.expectMsgType[AnnouncementSignatures] + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, spliceTx).contains(ann.shortChannelId)) } alice2bob.forward(bob) - bob2alice.expectMsgType[AnnouncementSignatures] // Alice doesn't receive Bob's signatures. + // Alice doesn't receive Bob's signatures. + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, spliceTx).contains(ann.shortChannelId)) } awaitAssert(bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.nonEmpty) val spliceAnn = bob.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.get assert(spliceAnn.shortChannelId != shortChannelId) @@ -1623,16 +1634,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitAssert(assert(alice.stateName == OFFLINE)) // Alice and Bob reconnect. - reconnect(f) - bob2alice.expectNoMessage(100 millis) - assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid) // Alice resends `splice_locked` because she hasn't received Bob's announcement_signatures. - alice2bob.forward(bob) - alice2bob.expectNoMessage(100 millis) - assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid) // Bob resends `splice_locked` in response to Alice's `splice_locked` after channel_reestablish. - bob2alice.forward(alice) + val (channelReestablishAlice, channelReestablishBob) = reconnect(f) + assert(channelReestablishAlice.retransmitAnnSigs) + assert(channelReestablishAlice.myCurrentFundingLocked_opt.contains(spliceTx.txid)) + assert(!channelReestablishBob.retransmitAnnSigs) + assert(channelReestablishBob.myCurrentFundingLocked_opt.contains(spliceTx.txid)) assert(bob2alice.expectMsgType[AnnouncementSignatures].shortChannelId == spliceAnn.shortChannelId) bob2alice.forward(alice) bob2alice.expectNoMessage(100 millis) + alice2bob.expectNoMessage(100 millis) assert(aliceListener.expectMsgType[ShortChannelIdAssigned].announcement_opt.contains(spliceAnn)) awaitAssert(assert(alice.stateData.asInstanceOf[DATA_NORMAL].lastAnnouncement_opt.contains(spliceAnn))) awaitAssert(assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.all.size == 1)) @@ -1705,6 +1715,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Bob disconnects before receiving Alice's commit_sig. disconnect(f) reconnect(f) + alice2bob.expectMsgType[ChannelReady] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReady] + bob2alice.forward(alice) alice2bob.expectMsgType[UpdateAddHtlc] alice2bob.forward(bob) val sigsA = alice2bob.expectMsgType[CommitSigBatch] @@ -1888,6 +1902,31 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik (channelReestablishAlice, channelReestablishBob) } + private def reconnectWithLegacyPeer(f: FixtureParam, sendReestablish: Boolean = true): (ChannelReestablish, ChannelReestablish) = { + import f._ + + // Modify both nodes' state data so they see each other as using the legacy splice protocol. + // This must be done before INPUT_RECONNECTED because the channel_reestablish is constructed using the current state data. + Seq(alice, bob).foreach { node => + val data = node.stateData.asInstanceOf[DATA_NORMAL] + val newData = data.modify(_.commitments.channelParams.remoteParams.initFeatures).using { features => + features.remove(Features.Splicing).add(Features.SplicePrototype, FeatureSupport.Optional) + } + node.setState(node.stateName, newData) + } + + // Use legacy features for reconnection so that updateFeatures preserves the legacy setting. + val baseFeatures = alice.commitments.localChannelParams.initFeatures + val legacyInit = Init(baseFeatures.remove(Features.Splicing).add(Features.SplicePrototype, FeatureSupport.Optional)) + alice ! INPUT_RECONNECTED(alice2bob.ref, legacyInit, legacyInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, legacyInit, legacyInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + if (sendReestablish) alice2bob.forward(bob) + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + if (sendReestablish) bob2alice.forward(alice) + (channelReestablishAlice, channelReestablishBob) + } + test("disconnect (tx_complete not received)") { f => import f._ // Disconnection with one side sending commit_sig @@ -1914,8 +1953,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik reconnect(f) // Bob and Alice will exchange tx_abort because Bob did not receive Alice's tx_complete before the disconnect. + bob2alice.expectMsgType[ChannelReady] + bob2alice.forward(alice) bob2alice.expectMsgType[TxAbort] bob2alice.forward(alice) + alice2bob.expectMsgType[ChannelReady] + alice2bob.forward(bob) alice2bob.expectMsgType[TxAbort] alice2bob.forward(bob) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) @@ -1957,14 +2000,16 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) - // If Bob has not implemented https://github.com/lightning/bolts/pull/1214, he will send an incorrect next_commitment_number. + // If Bob has not implemented https://github.com/lightning/bolts/pull/1214, he will not ask for a retransmission of commit_sig. val (channelReestablishAlice1, channelReestablishBob1) = reconnect(f, sendReestablish = false) assert(channelReestablishAlice1.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) - assert(channelReestablishAlice1.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishAlice1.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice1.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob1.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) - assert(channelReestablishBob1.nextLocalCommitmentNumber == bobCommitIndex) + assert(channelReestablishBob1.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob1.nextLocalCommitmentNumber == bobCommitIndex + 1) alice2bob.forward(bob, channelReestablishAlice1) - bob2alice.forward(alice, channelReestablishBob1.copy(nextLocalCommitmentNumber = bobCommitIndex + 1)) + bob2alice.forward(alice, channelReestablishBob1.copy(tlvStream = TlvStream(channelReestablishBob1.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv])))) // In that case Alice won't retransmit commit_sig and the splice won't complete since they haven't exchanged tx_signatures. assert(bob2alice.expectMsgType[CommitSig].fundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) bob2alice.forward(alice) @@ -1978,13 +2023,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice ! cmd.copy(replyTo = probe.ref) probe.expectMsgType[RES_ADD_FAILED[ForbiddenDuringSplice]] - // But when correctly setting their next_commitment_number, they're able to finalize the splice. + // But when correctly setting their next_funding TLV, they're able to finalize the splice. disconnect(f) val (channelReestablishAlice2, channelReestablishBob2) = reconnect(f) assert(channelReestablishAlice2.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + assert(!channelReestablishAlice2.retransmitInteractiveTxCommitSig) assert(channelReestablishAlice2.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob2.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) - assert(channelReestablishBob2.nextLocalCommitmentNumber == bobCommitIndex) + assert(channelReestablishBob2.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob2.nextLocalCommitmentNumber == bobCommitIndex + 1) // Alice retransmits commit_sig and both retransmit tx_signatures. assert(alice2bob.expectMsgType[CommitSig].fundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) @@ -2011,6 +2058,128 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } + test("disconnect (commit_sig not received) with legacy peer") { f => + import f._ + + val htlcs = setupHtlcs(f) + val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + + val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig + bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceWaitingForSigs]) + val spliceStatus = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs] + + disconnect(f) + + val (channelReestablishAlice, channelReestablishBob) = reconnectWithLegacyPeer(f) + + // Experimental protocol uses an experimental TLV. + assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + assert(channelReestablishAlice.tlvStream.get[ChannelReestablishTlv.ExperimentalNextFundingTlv].map(_.txId).contains(spliceStatus.signingSession.fundingTx.txId)) + // Experimental protocol doesn't use the explicit retransmit flag. + assert(!channelReestablishAlice.retransmitInteractiveTxCommitSig) + // Experimental protocol rolls back nextLocalCommitmentNumber to signal commit_sig wasn't received. + assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex) + + // Legacy peers always retransmit channel_ready for the initial funding. + alice2bob.expectMsgType[ChannelReady] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReady] + bob2alice.forward(alice) + + // Both sides retransmit commit_sig. + assert(alice2bob.expectMsgType[CommitSig].fundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + alice2bob.forward(bob) + assert(bob2alice.expectMsgType[CommitSig].fundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + bob2alice.forward(alice) + bob2alice.expectMsgType[TxSignatures] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxSignatures] + alice2bob.forward(bob) + sender.expectMsgType[RES_SPLICE] + + val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + assert(spliceTx.txid == spliceStatus.signingSession.fundingTx.txId) + alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) + bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) + alice ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) + alice2bob.expectMsgType[SpliceLocked] + alice2bob.forward(bob) + bob ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) + bob2alice.expectMsgType[SpliceLocked] + bob2alice.forward(alice) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs) + } + + test("re-send splice_locked for legacy peers") { f => + import f._ + + val fundingTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + checkWatchConfirmed(f, fundingTx) + + // Both sides confirm the splice and exchange splice_locked. + alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) + alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) + assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + alice2bob.forward(bob) + bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) + bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) + assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + bob2alice.forward(alice) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + disconnect(f) + val (channelReestablishAlice, channelReestablishBob) = reconnectWithLegacyPeer(f) + + // With the experimental protocol, peers retransmit splice_locked on reconnection. + assert(channelReestablishAlice.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + assert(channelReestablishBob.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + assert(!channelReestablishAlice.retransmitAnnSigs) + assert(!channelReestablishBob.retransmitAnnSigs) + assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + alice2bob.forward(bob) + assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + bob2alice.forward(alice) + } + + test("don't re-send splice_locked on reconnection") { f => + import f._ + + val fundingTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + checkWatchConfirmed(f, fundingTx) + + // Both sides confirm the splice and exchange splice_locked. + alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) + alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) + assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + alice2bob.forward(bob) + bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) + bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) + assert(bob2alice.expectMsgType[SpliceLocked].fundingTxId == fundingTx.txid) + bob2alice.forward(alice) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + + disconnect(f) + val (channelReestablishAlice, channelReestablishBob) = reconnect(f) + + // No splice_locked or other messages are retransmitted: my_current_funding_locked implies splice_locked. + assert(channelReestablishAlice.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + assert(channelReestablishBob.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + alice2bob.expectNoMessage(100 millis) + bob2alice.expectNoMessage(100 millis) + } + test("disconnect (commit_sig not received, missing current nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ @@ -2124,9 +2293,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTxId)) + assert(!channelReestablishAlice.retransmitInteractiveTxCommitSig) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTxId)) - assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) commitmentFormat match { case _: SegwitV0CommitmentFormat => () case _: SimpleTaprootChannelCommitmentFormat => @@ -2194,8 +2365,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + assert(!channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) commitmentFormat match { case _: SegwitV0CommitmentFormat => () @@ -2419,8 +2592,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice retransmits tx_signatures. alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - assert(alice2bob.expectMsgType[SpliceLocked].fundingTxId == spliceTx.txid) - alice2bob.forward(bob) bob2alice.expectNoMessage(100 millis) bob ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) bob2alice.expectMsgType[SpliceLocked] @@ -2453,13 +2624,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.isEmpty) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTx.txid)) + bob2alice.expectMsgType[ChannelReady] bob2alice.expectNoMessage(100 millis) // Bob receives Alice's tx_signatures, which completes the splice. alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - alice2bob.expectMsgType[SpliceLocked] - alice2bob.forward(bob) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) } @@ -2532,9 +2702,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) // Alice and Bob retransmit commit_sig and tx_signatures. @@ -2579,9 +2751,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) + assert(!channelReestablishAlice.retransmitInteractiveTxCommitSig) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex) + assert(channelReestablishBob.retransmitInteractiveTxCommitSig) + assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) // Alice retransmits commit_sig, and they exchange tx_signatures afterwards. @@ -2629,9 +2803,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) - assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishAlice.retransmitInteractiveTxCommitSig) + assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) + assert(!channelReestablishBob.retransmitInteractiveTxCommitSig) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { @@ -2742,16 +2918,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // From Alice's point of view, we now have two unconfirmed splices. - alice2bob.ignoreMsg { case _: ChannelUpdate => true } - bob2alice.ignoreMsg { case _: ChannelUpdate => true } + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } disconnect(f) - reconnect(f) - - // NB: channel_ready are not re-sent because the channel has already been used (for building splices). - // Alice has already received `splice_locked` from Bob for the first splice, so he doesn't need to resend it. - bob2alice.expectNoMessage(100 millis) - alice2bob.expectNoMessage(100 millis) + val (channelReestablishA1, channelReestablishB1) = reconnect(f) + // Alice has locked the initial funding transaction, but not the splice transaction yet. + assert(channelReestablishA1.myCurrentFundingLocked_opt.nonEmpty) + assert(!channelReestablishA1.myCurrentFundingLocked_opt.contains(fundingTx1.txid)) + assert(channelReestablishB1.myCurrentFundingLocked_opt.contains(fundingTx1.txid)) // The first splice confirms on Alice's side. alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) @@ -2761,11 +2936,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingInput.txid) disconnect(f) - reconnect(f) - - // Alice and Bob have already exchanged `splice_locked` for the first splice, so there is need to resend it. - bob2alice.expectNoMessage(100 millis) - alice2bob.expectNoMessage(100 millis) + val (channelReestablishA2, channelReestablishB2) = reconnect(f) + assert(channelReestablishA2.myCurrentFundingLocked_opt.contains(fundingTx1.txid)) + assert(channelReestablishB2.myCurrentFundingLocked_opt.contains(fundingTx1.txid)) // The second splice confirms on Alice's side. alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2) @@ -2775,10 +2948,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectMsgTypeHaving[UnwatchFundingSpent](_.txId == fundingTx1.txid) disconnect(f) - reconnect(f) - - alice2bob.expectNoMessage(100 millis) - bob2alice.expectNoMessage(100 millis) + val (channelReestablishA3, channelReestablishB3) = reconnect(f) + assert(channelReestablishA3.myCurrentFundingLocked_opt.contains(fundingTx2.txid)) + assert(channelReestablishB3.myCurrentFundingLocked_opt.contains(fundingTx1.txid)) // The second splice confirms on Bob's side. bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2) @@ -2788,14 +2960,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // NB: we disconnect *before* transmitting the splice_locked to Alice. disconnect(f) - reconnect(f) - - alice2bob.expectNoMessage(100 millis) - bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx2.txid) - // This time alice received the splice_locked for the second splice. - bob2alice.forward(alice) - alice2bob.expectNoMessage(100 millis) - bob2alice.expectNoMessage(100 millis) + val (channelReestablishA4, channelReestablishB4) = reconnect(f) + assert(channelReestablishA4.myCurrentFundingLocked_opt.contains(fundingTx2.txid)) + assert(channelReestablishB4.myCurrentFundingLocked_opt.contains(fundingTx2.txid)) disconnect(f) reconnect(f) @@ -2873,6 +3040,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + // Bob will not receive Alice's tx_signatures, update_add_htlc or commit_sigs before disconnecting. disconnect(f) reconnect(f) @@ -2911,8 +3081,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) alice2bob.forward(bob) - alice2bob.ignoreMsg { case _: ChannelUpdate => true } - bob2alice.ignoreMsg { case _: ChannelUpdate => true } + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } disconnect(f) @@ -2926,19 +3096,15 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.size == 2) assert(bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.size == 1) - reconnect(f) + val (channelReestablishA, channelReestablishB) = reconnect(f) + assert(channelReestablishA.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + assert(channelReestablishB.myCurrentFundingLocked_opt.contains(fundingTx.txid)) - // Because `your_last_funding_locked_txid` from Bob matches the last `splice_locked` txid sent by Alice; there is no need - // for Alice to resend `splice_locked`. Alice processes the `my_current_funding_locked` from Bob as if she received - // `splice_locked` from Bob and prunes the initial funding commitment. + // Alice processes the `my_current_funding_locked` from Bob as if she received `splice_locked` from Bob and prunes the initial funding commitment. awaitCond(alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.size == 1) assert(alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.head.fundingTxId == fundingTx.txid) alice2bob.expectNoMessage(100 millis) - // The `your_last_funding_locked_txid` from Alice does not match the last `splice_locked` sent by Bob, so Bob must resend `splice_locked`. - val bobSpliceLocked = bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) - assert(bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.size == 1) - // Alice sends an HTLC before receiving Bob's splice_locked: see https://github.com/lightning/bolts/issues/1223. addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) val sender = TestProbe() @@ -2946,7 +3112,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] assert(alice2bob.expectMsgType[CommitSig].fundingTxId_opt.contains(fundingTx.txid)) alice2bob.forward(bob) - bob2alice.forward(alice, bobSpliceLocked) bob2alice.expectMsgType[RevokeAndAck] bob2alice.forward(alice) assert(bob2alice.expectMsgType[CommitSig].fundingTxId_opt.contains(fundingTx.txid)) @@ -3055,6 +3220,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + // Bob will not receive Alice's commit_sigs before disconnecting. disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) @@ -3125,6 +3293,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + // Alice will not receive Bob's commit_sigs before disconnecting. disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) @@ -3192,6 +3363,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) + alice2bob.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelUpdate | _: ChannelReady => true } + // Alice will not receive Bob's commit_sigs before disconnecting. disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) @@ -3232,40 +3406,23 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) // Alice sends announcement_signatures to Bob. - alice2bob.expectMsgType[AnnouncementSignatures] + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } alice2bob.forward(bob) // Alice disconnects before Bob can send announcement_signatures. - bob2alice.expectMsgType[AnnouncementSignatures] + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } disconnect(f) - reconnect(f) - - // Bob will not resend `splice_locked` because he has already received `announcement_signatures` from Alice. - bob2alice.expectNoMessage(100 millis) - - // Alice resends `splice_locked` because she did not receive `announcement_signatures` from Bob before the disconnect. - val aliceSpliceLocked = alice2bob.expectMsgType[SpliceLocked] - alice2bob.forward(bob) - alice2bob.expectNoMessage(100 millis) - - // Bob receives Alice's `splice_locked` after `channel_reestablish` and must retransmit both `splice_locked` and `announcement_signatures`. - val bobSpliceLocked = bob2alice.expectMsgType[SpliceLocked] - bob2alice.forward(alice) - bob2alice.expectMsgType[AnnouncementSignatures] + val (channelReestablishA, channelReestablishB) = reconnect(f) + assert(channelReestablishA.retransmitAnnSigs) + assert(!channelReestablishB.retransmitAnnSigs) + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } bob2alice.forward(alice) bob2alice.expectNoMessage(100 millis) - - // Alice retransmits `announcement_signatures` to Bob after receiving `splice_locked` from Bob. - alice2bob.expectMsgType[AnnouncementSignatures] - alice2bob.forward(bob) alice2bob.expectNoMessage(100 millis) - bob2alice.expectNoMessage(100 millis) - // If either node receives `splice_locked` again, it should be ignored; `announcement_signatures have already been sent. - alice2bob.forward(bob, aliceSpliceLocked) - bob2alice.forward(alice, bobSpliceLocked) - alice2bob.expectNoMessage(100 millis) + // If Bob receives `splice_locked` again, it should be ignored; `announcement_signatures have already been sent. + alice2bob.forward(bob, SpliceLocked(alice.commitments.channelId, fundingTx.txid)) bob2alice.expectNoMessage(100 millis) // the splice is locked on both sides @@ -3279,41 +3436,39 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } } - test("disconnect before receiving splice_locked from a legacy peer") { f => + test("disconnect before receiving announcement_signatures from one peer (splice locked on one side only)", Tag(ChannelStateTestsTags.ChannelsPublic)) { f => import f._ val fundingTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) checkWatchConfirmed(f, fundingTx) - // The splice confirms for both. - alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) + // The splice confirms on Alice's side. + alice ! WatchFundingConfirmedTriggered(BlockHeight(420000), 42, fundingTx) alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) alice2bob.forward(bob) - bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx) - bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) - bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) - bob2alice.forward(alice) - - alice2bob.ignoreMsg { case _: ChannelUpdate => true } - bob2alice.ignoreMsg { case _: ChannelUpdate => true } + alice2bob.expectNoMessage(100 millis) + // The splice confirms on Bob's side while offline. disconnect(f) - val (aliceReestablish, bobReestablish) = reconnect(f, sendReestablish = false) + bob ! WatchFundingConfirmedTriggered(BlockHeight(420000), 42, fundingTx) + bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx.txid) - // remove the last_funding_locked tlv from the reestablish messages - alice2bob.forward(bob, aliceReestablish.copy(tlvStream = TlvStream.empty)) - bob2alice.forward(alice, bobReestablish.copy(tlvStream = TlvStream.empty)) + // On reconnection, both nodes want to exchange announcement_signatures for the splice. + val (channelReestablishA, channelReestablishB) = reconnect(f) + assert(channelReestablishA.retransmitAnnSigs) + assert(channelReestablishA.myCurrentFundingLocked_opt.contains(fundingTx.txid)) + assert(channelReestablishB.retransmitAnnSigs) + assert(channelReestablishB.myCurrentFundingLocked_opt.contains(fundingTx.txid)) - // always send last splice_locked after reconnection if the last_funding_locked tlv is not set - alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) - bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx.txid) + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } alice2bob.forward(bob) + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } bob2alice.forward(alice) - alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) + alice2bob.expectNoMessage(100 millis) - // the splice is locked on both sides + // The splice is locked on both sides. alicePeer.fishForMessage() { case e: ChannelReadyForPayments => e.fundingTxIndex == 1 case _ => false @@ -3344,38 +3499,24 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) // Alice sends announcement_signatures to Bob. - alice2bob.expectMsgType[AnnouncementSignatures] - + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } // Bob sends announcement_signatures to Alice. - bob2alice.expectMsgType[AnnouncementSignatures] + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } disconnect(f) reconnect(f) - // Bob resends `splice_locked` because he did not receive `announcement_signatures` from Alice before the disconnect. - val bobSpliceLocked = bob2alice.expectMsgType[SpliceLocked] - bob2alice.expectNoMessage(100 millis) - - // Alice resends `splice_locked` because she did not receive `announcement_signatures` from Bob before the disconnect. - val aliceSpliceLocked = alice2bob.expectMsgType[SpliceLocked] + inside(alice2bob.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } alice2bob.forward(bob) alice2bob.expectNoMessage(100 millis) - // Alice receives Bob's `splice_locked` after already resending their `splice_locked` and retransmits `announcement_signatures`. + inside(bob2alice.expectMsgType[AnnouncementSignatures]) { ann => assert(getFundingScid(f, fundingTx).contains(ann.shortChannelId)) } bob2alice.forward(alice) - alice2bob.expectMsgType[AnnouncementSignatures] - alice2bob.forward(bob) - alice2bob.expectNoMessage(100 millis) - - // Bob retransmits `announcement_signatures` to Alice after receiving `announcement_signatures` from Alice. - bob2alice.expectMsgType[AnnouncementSignatures] - bob2alice.forward(alice) - alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) // If either node receives `splice_locked` again, it should be ignored; `announcement_signatures have already been sent. - alice2bob.forward(bob, aliceSpliceLocked) - bob2alice.forward(alice, bobSpliceLocked) + alice2bob.forward(bob, SpliceLocked(alice.commitments.channelId, fundingTx.txid)) + bob2alice.forward(alice, SpliceLocked(bob.commitments.channelId, fundingTx.txid)) alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index 5ce9c41c2d..2e69a3dbb6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -71,9 +71,12 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } } - private def lastFundingLockedTlvs(commitments: Commitments): Set[ChannelReestablishTlv] = - commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++ - commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet + private def lastFundingLockedTlvs(commitments: Commitments): Set[ChannelReestablishTlv] = { + commitments.lastLocalLocked_opt.map(c => { + val retransmitAnnSigs = commitments.announceChannel + ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId, retransmitAnnSigs) + }).toSet + } test("reconnect after creating channel", Tag(IgnoreChannelUpdates)) { f => import f._ @@ -120,10 +123,12 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with disconnect(alice, bob) val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) - val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) - val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.commitments)))) + val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.commitments)))) alice2bob.forward(bob, reestablishA) bob2alice.forward(alice, reestablishB) + alice2bob.expectMsgType[ChannelReady] + bob2alice.expectMsgType[ChannelReady] // alice will re-send the update and the sig alice2bob.expectMsg(htlc) @@ -175,8 +180,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with disconnect(alice, bob) val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) - val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) - val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 0, PrivateKey(ByteVector32.Zeroes), aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.commitments)))) + val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.commitments)))) alice2bob.forward(bob, reestablishA) bob2alice.forward(alice, reestablishB) @@ -223,8 +228,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with { val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) - val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) - val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 1, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.commitments)))) + val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.commitments)))) alice2bob.forward(bob, reestablishA) bob2alice.forward(alice, reestablishB) } @@ -254,8 +259,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with { val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) - val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 2, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) - val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 2, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.commitments)))) + val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.commitments)))) alice2bob.forward(bob, reestablishA) bob2alice.forward(alice, reestablishB) } @@ -715,21 +720,23 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with reconnect(alice, bob, alice2bob, bob2alice) // Alice and Bob exchange channel_reestablish and channel_ready again. - alice2bob.expectMsgType[ChannelReestablish] - bob2alice.expectMsgType[ChannelReestablish] + assert(!alice2bob.expectMsgType[ChannelReestablish].retransmitAnnSigs) + assert(bob2alice.expectMsgType[ChannelReestablish].retransmitAnnSigs) bob2alice.forward(alice) alice2bob.forward(bob) - alice2bob.expectNoMessage(100 millis) - - // Bob retransmits his channel_ready and announcement_signatures because he hasn't received Alice's announcement_signatures. + alice2bob.expectMsgType[ChannelReady] + alice2bob.forward(bob) bob2alice.expectMsgType[ChannelReady] bob2alice.forward(alice) + // Alice retransmits announcement_signatures because Bob requested it. + val annSigsAlice = alice2bob.expectMsgType[AnnouncementSignatures] + alice2bob.forward(bob) + alice2bob.expectNoMessage(100 millis) + + // Bob retransmits announcement_signatures because he hasn't received Alice's announcement_signatures. val annSigsBob = bob2alice.expectMsgType[AnnouncementSignatures] bob2alice.forward(alice, annSigsBob) - // Alice retransmits her announcement_signatures when receiving Bob's. - val annSigsAlice = alice2bob.expectMsgType[AnnouncementSignatures] - alice2bob.forward(bob, annSigsAlice) // Alice and Bob ignore redundant announcement_signatures. alice2bob.forward(bob, annSigsAlice) bob2alice.expectNoMessage(100 millis) @@ -749,6 +756,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2alice.expectMsgType[ChannelReestablish] bob2alice.forward(alice) alice2bob.forward(bob) + alice2bob.expectMsgType[ChannelReady] + bob2alice.expectMsgType[ChannelReady] // alice and bob resend their channel update at reconnection (unannounced channel) alice2bob.expectMsgType[ChannelUpdate] @@ -907,8 +916,6 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2alice.expectMsgType[ChannelReestablish] bob2alice.forward(alice) alice2bob.forward(bob) - bob2alice.expectMsgType[ChannelReady] - bob2alice.forward(alice) // Alice will NOT resend their channel_ready at reconnection because she has received bob's announcement_signatures (pre-splice behavior). alice2bob.expectNoMessage(100 millis) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index 2558de4126..3db9f6fce5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -159,9 +159,16 @@ class LightningMessageCodecsSpec extends AnyFunSuite { hex"0023" ++ channelId ++ signature ++ hex"fe47010000 07 cccccccccccccc" -> FundingSigned(channelId, signature, TlvStream[FundingSignedTlv](Set.empty[FundingSignedTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point), - hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"00 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingTlv(txId))), - hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.YourLastFundingLockedTlv(txId))), - hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"03 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"00 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.ExperimentalNextFundingTlv(txId))), + // TODO: replace those test vectors with the commented ones below when we remove support for the legacy splicing protocol. + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv(txId.value.reverse))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 21" ++ txId.value.reverse ++ hex"00" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv(txId.value.reverse ++ hex"00"))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 21" ++ txId.value.reverse ++ hex"01" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv(txId.value.reverse ++ hex"01"))), + // hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 21" ++ txId.value.reverse ++ hex"00" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingTlv(txId, retransmitCommitSig = false))), + // hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 21" ++ txId.value.reverse ++ hex"01" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingTlv(txId, retransmitCommitSig = true))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"03 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.ExperimentalMyCurrentFundingLockedTlv(txId))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"05 21" ++ txId.value.reverse ++ hex"00" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId, retransmitAnnSigs = false))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"05 21" ++ txId.value.reverse ++ hex"01" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId, retransmitAnnSigs = true))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"18 42" ++ nonce.data -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.CurrentCommitNonceTlv(nonce))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"16 c4" ++ txId.value.reverse ++ nonce.data ++ nextTxId.value.reverse ++ nextNonce.data -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextLocalNoncesTlv(Seq(txId -> nonce, nextTxId -> nextNonce)))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"fe47010000 00" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream[ChannelReestablishTlv](Set.empty[ChannelReestablishTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), @@ -854,4 +861,85 @@ class LightningMessageCodecsSpec extends AnyFunSuite { assert(lightningMessageCodec.encode(ref).require.bytes == bin) } } + + test("channel_reestablish backwards-compatibility with legacy splice TLVs") { + val channelId = randomBytes32() + val key = randomKey() + val point = randomKey().publicKey + val txId1 = randomTxId() + val txId2 = randomTxId() + val txId3 = randomTxId() + + def reestablish(tlvs: ChannelReestablishTlv*): ChannelReestablish = { + ChannelReestablish(channelId, 1, 0, key, point, TlvStream(tlvs: _*)) + } + + // Legacy TLVs: tag 0 (experimental next_funding) + tag 1 (experimental your_last_funding_locked) + tag 3 (experimental my_current_funding_locked). + { + val msg = reestablish( + ChannelReestablishTlv.ExperimentalNextFundingTlv(txId1), + ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asExperimentalYourLastFundingLocked(txId2), + ChannelReestablishTlv.ExperimentalMyCurrentFundingLockedTlv(txId3), + ) + assert(msg.nextFundingTxId_opt.contains(txId1)) + assert(!msg.retransmitInteractiveTxCommitSig) + assert(msg.yourLastFundingLocked_opt.contains(txId2)) + assert(msg.myCurrentFundingLocked_opt.contains(txId3)) + assert(!msg.retransmitAnnSigs) + val encoded = lightningMessageCodec.encode(msg).require + assert(lightningMessageCodec.decode(encoded).require.value == msg) + } + + // Official TLVs with retransmit flags set: tag 1 (official next_funding) + tag 5 (my_current_funding_locked). + { + val msg = reestablish( + ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(txId1, retransmitCommitSig = true), + ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId2, retransmitAnnSigs = true), + ) + assert(msg.nextFundingTxId_opt.contains(txId1)) + assert(msg.retransmitInteractiveTxCommitSig) + assert(msg.yourLastFundingLocked_opt.isEmpty) + assert(msg.myCurrentFundingLocked_opt.contains(txId2)) + assert(msg.retransmitAnnSigs) + val encoded = lightningMessageCodec.encode(msg).require + assert(lightningMessageCodec.decode(encoded).require.value == msg) + } + + // Official TLVs with retransmit flags unset. + { + val msg = reestablish( + ChannelReestablishTlv.NextFundingOrExperimentalYourLastFundingLockedTlv.asNextFunding(txId1, retransmitCommitSig = false), + ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId2, retransmitAnnSigs = false), + ) + assert(msg.nextFundingTxId_opt.contains(txId1)) + assert(!msg.retransmitInteractiveTxCommitSig) + assert(msg.yourLastFundingLocked_opt.isEmpty) + assert(msg.myCurrentFundingLocked_opt.contains(txId2)) + assert(!msg.retransmitAnnSigs) + val encoded = lightningMessageCodec.encode(msg).require + assert(lightningMessageCodec.decode(encoded).require.value == msg) + } + + // my_current_funding_locked priority: official tag 5 takes priority over legacy tag 3. + { + val msg = reestablish( + ChannelReestablishTlv.ExperimentalMyCurrentFundingLockedTlv(txId1), + ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId2, retransmitAnnSigs = false), + ) + assert(msg.myCurrentFundingLocked_opt.contains(txId2)) + val encoded = lightningMessageCodec.encode(msg).require + assert(lightningMessageCodec.decode(encoded).require.value == msg) + } + + // Empty TLV stream: all splice-related accessors return None/false. + { + val msg = ChannelReestablish(channelId, 1, 0, key, point) + assert(msg.nextFundingTxId_opt.isEmpty) + assert(!msg.retransmitInteractiveTxCommitSig) + assert(msg.yourLastFundingLocked_opt.isEmpty) + assert(msg.myCurrentFundingLocked_opt.isEmpty) + assert(!msg.retransmitAnnSigs) + } + } + } From 668becb06a7b7dc3e19a22b8cae00de097f1ee62 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 29 Apr 2026 14:46:32 +0200 Subject: [PATCH 3/4] Simplify rebase --- .../fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala index dafd75ead1..b8badd7d45 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala @@ -118,7 +118,9 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory test("add liquidity if on-the-fly funding is used", Tag(noPlugin)) { f => import f._ - val features = defaultFeatures.add(Features.Splicing, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) + val features = defaultFeatures + .add(Features.Splicing, FeatureSupport.Optional) + .add(Features.OnTheFlyFunding, FeatureSupport.Optional) val requestFunding = LiquidityAds.RequestFunding(250_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(randomBytes32() :: Nil)) val open = createOpenDualFundedChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Some(requestFunding)).copy( channelFlags = ChannelFlags(nonInitiatorPaysCommitFees = true, announceChannel = false), @@ -210,7 +212,9 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory test("reject on-the-fly channel if another channel exists", Tag(noPlugin)) { f => import f._ - val features = defaultFeatures.add(Features.Splicing, FeatureSupport.Optional).add(Features.OnTheFlyFunding, FeatureSupport.Optional) + val features = defaultFeatures + .add(Features.Splicing, FeatureSupport.Optional) + .add(Features.OnTheFlyFunding, FeatureSupport.Optional) val requestFunding = LiquidityAds.RequestFunding(250_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(randomBytes32() :: Nil)) val open = createOpenDualFundedChannelMessage(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), Some(requestFunding)).copy( channelFlags = ChannelFlags(nonInitiatorPaysCommitFees = true, announceChannel = false), From 318ba314d5bc9824e031d07b772c84920ccfd6ff Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 29 Apr 2026 15:02:30 +0200 Subject: [PATCH 4/4] Simplify rebase more --- .../wire/protocol/LightningMessageTypes.scala | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 7980608798..8f405b55dd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -149,12 +149,22 @@ case class TxSignatures(channelId: ByteVector32, } object TxSignatures { - def apply(channelId: ByteVector32, tx: Transaction, witnesses: Seq[ScriptWitness], previousFundingSig_opt: Option[ChannelSpendSignature]): TxSignatures = { - val tlvs: Set[TxSignaturesTlv] = previousFundingSig_opt match { - case Some(IndividualSignature(sig)) => Set(TxSignaturesTlv.PreviousFundingTxSig(sig), TxSignaturesTlv.ExperimentalPreviousFundingTxSig(sig)) - case Some(partialSig: PartialSignatureWithNonce) => Set(TxSignaturesTlv.PreviousFundingTxPartialSig(partialSig)) - case None => Set.empty - } + def apply(channelId: ByteVector32, + tx: Transaction, + witnesses: Seq[ScriptWitness], + previousFundingSig_opt: Option[ChannelSpendSignature]): TxSignatures = { + val tlvs: Set[TxSignaturesTlv] = Set( + previousFundingSig_opt match { + case Some(IndividualSignature(sig)) => Some(TxSignaturesTlv.PreviousFundingTxSig(sig)) + case Some(partialSig: PartialSignatureWithNonce) => Some(TxSignaturesTlv.PreviousFundingTxPartialSig(partialSig)) + case None => None + }, + // We keep supporting the experimental splicing protocol. + previousFundingSig_opt match { + case Some(IndividualSignature(sig)) => Some(TxSignaturesTlv.ExperimentalPreviousFundingTxSig(sig)) + case _ => None + } + ).flatten TxSignatures(channelId, tx.txid, witnesses, TlvStream(tlvs)) } }