diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index 37b5ab3e8..d792472ca 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt @@ -242,6 +242,13 @@ sealed class Feature { override val scopes: Set get() = setOf(FeatureScope.Init) } + @Serializable + object Quiescence : Feature() { + override val rfcName get() = "option_quiescence" + override val mandatory get() = 34 + override val scopes: Set get() = setOf(FeatureScope.Init) + } + } @Serializable @@ -320,6 +327,7 @@ data class Features(val activated: Map, val unknown: Se Feature.ChannelBackupClient, Feature.ChannelBackupProvider, Feature.ExperimentalSplice, + Feature.Quiescence ) operator fun invoke(bytes: ByteVector): Features = invoke(bytes.toByteArray()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index eb6e7889f..1db24c29b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -203,6 +203,7 @@ data class NodeParams( Feature.PayToOpenClient to FeatureSupport.Optional, Feature.ChannelBackupClient to FeatureSupport.Optional, Feature.ExperimentalSplice to FeatureSupport.Optional, + Feature.Quiescence to FeatureSupport.Mandatory ), dustLimit = 546.sat, maxRemoteDustLimit = 600.sat, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index ec278224f..bc322feb0 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -64,10 +64,11 @@ sealed class ChannelCommand { data class WatchReceived(val watch: WatchEvent) : ChannelCommand() sealed interface ForbiddenDuringSplice + sealed interface ForbiddenDuringQuiescence sealed class Htlc : ChannelCommand() { - data class Add(val amount: MilliSatoshi, val paymentHash: ByteVector32, val cltvExpiry: CltvExpiry, val onion: OnionRoutingPacket, val paymentId: UUID, val commit: Boolean = false) : Htlc(), ForbiddenDuringSplice + data class Add(val amount: MilliSatoshi, val paymentHash: ByteVector32, val cltvExpiry: CltvExpiry, val onion: OnionRoutingPacket, val paymentId: UUID, val commit: Boolean = false) : Htlc(), ForbiddenDuringSplice, ForbiddenDuringQuiescence - sealed class Settlement : Htlc(), ForbiddenDuringSplice { + sealed class Settlement : Htlc(), ForbiddenDuringSplice, ForbiddenDuringQuiescence { abstract val id: Long data class Fulfill(override val id: Long, val r: ByteVector32, val commit: Boolean = false) : Settlement() @@ -83,7 +84,7 @@ sealed class ChannelCommand { sealed class Commitment : ChannelCommand() { object Sign : Commitment(), ForbiddenDuringSplice - data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice + data class UpdateFee(val feerate: FeeratePerKw, val commit: Boolean = false) : Commitment(), ForbiddenDuringSplice, ForbiddenDuringQuiescence object CheckHtlcTimeout : Commitment() sealed class Splice : Commitment() { data class Request(val replyTo: CompletableDeferred, val spliceIn: SpliceIn?, val spliceOut: SpliceOut?, val feerate: FeeratePerKw, val origins: List = emptyList()) : Splice() { @@ -111,7 +112,8 @@ sealed class ChannelCommand { object InsufficientFunds : Failure() object InvalidSpliceOutPubKeyScript : Failure() object SpliceAlreadyInProgress : Failure() - object ChannelNotIdle : Failure() + object ConcurrentRemoteSplice : Failure() + object ChannelNotQuiescent : Failure() data class FundingFailure(val reason: FundingContributionFailure) : Failure() object CannotStartSession : Failure() data class InteractiveTxSessionFailed(val reason: InteractiveTxSessionAction.RemoteFailure) : Failure() @@ -124,7 +126,7 @@ sealed class ChannelCommand { } sealed class Close : ChannelCommand() { - data class MutualClose(val scriptPubKey: ByteVector?, val feerates: ClosingFeerates?) : Close() + data class MutualClose(val scriptPubKey: ByteVector?, val feerates: ClosingFeerates?) : Close(), ForbiddenDuringSplice, ForbiddenDuringQuiescence object ForceClose : Close() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index ae2fa44a7..b2f960a8a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -39,7 +39,7 @@ data class InvalidRbfNonInitiator (override val channelId: Byte data class InvalidRbfAttempt (override val channelId: ByteVector32) : ChannelException(channelId, "invalid rbf attempt") data class InvalidSpliceAlreadyInProgress (override val channelId: ByteVector32) : ChannelException(channelId, "invalid splice attempt: the current splice attempt must be completed or aborted first") data class InvalidSpliceAbortNotAcked (override val channelId: ByteVector32) : ChannelException(channelId, "invalid splice attempt: our previous tx_abort has not been acked") -data class InvalidSpliceChannelNotIdle (override val channelId: ByteVector32) : ChannelException(channelId, "invalid splice attempt: channel is not idle") +data class InvalidSpliceNotQuiescent (override val channelId: ByteVector32) : ChannelException(channelId, "invalid splice attempt: the channel is not quiescent") data class NoMoreHtlcsClosingInProgress (override val channelId: ByteVector32) : ChannelException(channelId, "cannot send new htlcs, closing in progress") data class ClosingAlreadyInProgress (override val channelId: ByteVector32) : ChannelException(channelId, "closing already in progress") data class CannotCloseWithUnsignedOutgoingHtlcs (override val channelId: ByteVector32) : ChannelException(channelId, "cannot close when there are unsigned outgoing htlc") @@ -83,4 +83,6 @@ data class InvalidFailureCode (override val channelId: Byte data class PleasePublishYourCommitment (override val channelId: ByteVector32) : ChannelException(channelId, "please publish your local commitment") data class CommandUnavailableInThisState (override val channelId: ByteVector32, val state: String) : ChannelException(channelId, "cannot execute command in state=$state") data class ForbiddenDuringSplice (override val channelId: ByteVector32, val command: String?) : ChannelException(channelId, "cannot process $command while splicing") +data class ForbiddenDuringQuiescence (override val channelId: ByteVector32, val command: String?) : ChannelException(channelId, "cannot process $command while quiescent") +data class InvalidSpliceRequest (override val channelId: ByteVector32) : ChannelException(channelId, "invalid splice request") // @formatter:on diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt index 1408bbb26..d4e1f17c3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -241,6 +241,10 @@ data class Commitment( return hasNoPendingHtlcs() && hasNoPendingFeeUpdate } + fun hasPendingOrProposedHtlcs(changes: CommitmentChanges): Boolean { + return !hasNoPendingHtlcs() || changes.localChanges.all.filterIsInstance().isNotEmpty() || changes.remoteChanges.all.filterIsInstance().isNotEmpty() + } + fun isIdle(changes: CommitmentChanges): Boolean = hasNoPendingHtlcs() && changes.localChanges.all.isEmpty() && changes.remoteChanges.all.isEmpty() fun timedOutOutgoingHtlcs(blockHeight: Long): Set { @@ -554,9 +558,13 @@ data class Commitments( } // @formatter:off + fun localIsQuiescent(): Boolean = changes.localChanges.all.isEmpty() + fun remoteIsQuiescent(): Boolean = changes.remoteChanges.all.isEmpty() + fun isQuiescent(): Boolean = localIsQuiescent() && remoteIsQuiescent() // HTLCs and pending changes are the same for all active commitments, so we don't need to loop through all of them. - fun isIdle(): Boolean = active.first().isIdle(changes) fun hasNoPendingHtlcsOrFeeUpdate(): Boolean = active.first().hasNoPendingHtlcsOrFeeUpdate(changes) + fun hasPendingOrProposedHtlcs(): Boolean = active.first().hasPendingOrProposedHtlcs(changes) + fun timedOutOutgoingHtlcs(currentHeight: Long): Set = active.first().timedOutOutgoingHtlcs(currentHeight) fun almostTimedOutIncomingHtlcs(currentHeight: Long, fulfillSafety: CltvExpiryDelta): Set = active.first().almostTimedOutIncomingHtlcs(currentHeight, fulfillSafety, changes) fun getOutgoingHtlcCrossSigned(htlcId: Long): UpdateAddHtlc? = active.first().getOutgoingHtlcCrossSigned(htlcId) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 3b0086c74..df59549c4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -877,10 +877,33 @@ sealed class RbfStatus { object RbfAborted : RbfStatus() } +/** We're waiting for the channel to be quiescent. */ +sealed class QuiescenceNegotiation : SpliceStatus() { + abstract class Initiator : QuiescenceNegotiation() { + abstract val command: ChannelCommand.Commitment.Splice.Request + } + abstract class NonInitiator : QuiescenceNegotiation() +} + +/** The channel is quiescent and a splice attempt was initiated. */ +sealed class QuiescentSpliceStatus : SpliceStatus() + sealed class SpliceStatus { object None : SpliceStatus() - data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : SpliceStatus() - data class InProgress(val replyTo: CompletableDeferred?, val spliceSession: InteractiveTxSession, val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, val origins: List) : SpliceStatus() - data class WaitingForSigs(val session: InteractiveTxSigningSession, val origins: List) : SpliceStatus() - object Aborted : SpliceStatus() + /** We stop sending new updates and wait for our updates to be added to the local and remote commitments. */ + data class QuiescenceRequested(override val command: ChannelCommand.Commitment.Splice.Request) : QuiescenceNegotiation.Initiator() + /** Our updates have been added to the local and remote commitments, we wait for our peer to do the same. */ + data class InitiatorQuiescent(override val command: ChannelCommand.Commitment.Splice.Request) : QuiescenceNegotiation.Initiator() + /** Our peer has asked us to stop sending new updates and wait for our updates to be added to the local and remote commitments. */ + data class ReceivedStfu(val stfu: Stfu) : QuiescenceNegotiation.NonInitiator() + /** Our updates have been added to the local and remote commitments, we wait for our peer to use the now quiescent channel. */ + object NonInitiatorQuiescent : QuiescentSpliceStatus() + /** We told our peer we want to splice funds in the channel. */ + data class Requested(val command: ChannelCommand.Commitment.Splice.Request, val spliceInit: SpliceInit) : QuiescentSpliceStatus() + /** We both agreed to splice and are building the splice transaction. */ + data class InProgress(val replyTo: CompletableDeferred?, val spliceSession: InteractiveTxSession, val localPushAmount: MilliSatoshi, val remotePushAmount: MilliSatoshi, val origins: List) : QuiescentSpliceStatus() + /** The splice transaction has been negotiated, we're exchanging signatures. */ + data class WaitingForSigs(val session: InteractiveTxSigningSession, val origins: List) : QuiescentSpliceStatus() + /** The splice attempt was aborted by us, we're waiting for our peer to ack. */ + object Aborted : QuiescentSpliceStatus() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt index a58b20c48..f8b5f2378 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt @@ -613,6 +613,9 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { null } } + + // in Normal during splice we cache htlc settlements and process them when we end quiescence + var settlementStash: List = emptyList() } object Channel { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index 739fe3c0d..d50687ea6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -29,9 +29,29 @@ data class Normal( override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) override fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { - if (cmd is ChannelCommand.ForbiddenDuringSplice && spliceStatus !is SpliceStatus.None) { + if (cmd is ChannelCommand.ForbiddenDuringQuiescence && spliceStatus is QuiescenceNegotiation) { + val error = ForbiddenDuringQuiescence(channelId, cmd::class.simpleName) + return when (cmd) { + is ChannelCommand.Htlc.Settlement -> { + // Htlc settlement commands are ignored and will be replayed when not quiescent. + // This could create issues if we're keeping htlcs that should be settled pending for too long, as they could timeout. + settlementStash += cmd + Pair(this@Normal, listOf()) + } + else -> handleCommandError(cmd, error, channelUpdate) + } + } + if (cmd is ChannelCommand.ForbiddenDuringSplice && spliceStatus is QuiescentSpliceStatus) { val error = ForbiddenDuringSplice(channelId, cmd::class.simpleName) - return handleCommandError(cmd, error, channelUpdate) + return when (cmd) { + is ChannelCommand.Htlc.Settlement -> { + // Htlc settlement commands are ignored and will be replayed when not quiescent. + // This could create issues if we're keeping htlcs that should be settled pending for too long, as they could timeout. + settlementStash += cmd + Pair(this@Normal, listOf()) + } + else -> handleCommandError(cmd, error, channelUpdate) + } } return when (cmd) { is ChannelCommand.Htlc.Add -> { @@ -98,39 +118,10 @@ data class Normal( is ChannelCommand.Funding.BumpFundingFee -> unhandled(cmd) is ChannelCommand.Commitment.Splice.Request -> when (spliceStatus) { is SpliceStatus.None -> { - if (commitments.isIdle()) { - val parentCommitment = commitments.active.first() - val fundingContribution = FundingContributions.computeSpliceContribution( - isInitiator = true, - commitment = parentCommitment, - walletInputs = cmd.spliceIn?.walletInputs ?: emptyList(), - localOutputs = cmd.spliceOutputs, - targetFeerate = cmd.feerate - ) - if (fundingContribution < 0.sat && parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params)) { - logger.warning { "cannot do splice: insufficient funds" } - cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) - Pair(this@Normal, emptyList()) - } else if (cmd.spliceOut?.scriptPubKey?.let { Helpers.Closing.isValidFinalScriptPubkey(it, allowAnySegwit = true) } == false) { - logger.warning { "cannot do splice: invalid splice-out script" } - cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript) - Pair(this@Normal, emptyList()) - } else { - val spliceInit = SpliceInit( - channelId, - fundingContribution = fundingContribution, - lockTime = currentBlockHeight.toLong(), - feerate = cmd.feerate, - fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), - pushAmount = cmd.pushAmount - ) - logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount}" } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.Requested(cmd, spliceInit)), listOf(ChannelAction.Message.Send(spliceInit))) - } + if (commitments.localIsQuiescent()) { + Pair(this@Normal.copy(spliceStatus = SpliceStatus.InitiatorQuiescent(cmd)), listOf(ChannelAction.Message.Send(Stfu(channelId, initiator = true)))) } else { - logger.warning { "cannot initiate splice, channel not idle" } - cmd.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotIdle) - Pair(this@Normal, emptyList()) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.QuiescenceRequested(cmd)), emptyList()) } } else -> { @@ -140,11 +131,20 @@ data class Normal( } } is ChannelCommand.MessageReceived -> when { - cmd.message is ForbiddenMessageDuringSplice && spliceStatus !is SpliceStatus.None && spliceStatus !is SpliceStatus.Requested -> { - // In case of a race between our splice_init and a forbidden message from our peer, we accept their message, because - // we know they are going to reject our splice attempt + cmd.message is ForbiddenMessageDuringSplice && spliceStatus is QuiescentSpliceStatus -> { + logger.warning {"received forbidden message ${cmd::class.simpleName} during splicing with status ${spliceStatus}" } val error = ForbiddenDuringSplice(channelId, cmd.message::class.simpleName) - handleLocalError(cmd, error) + val warn = ChannelAction.Message.Send(Warning(channelId, error.message)) + // Instead of force-closing (which would cost us on-chain fees), we try to resolve this issue by disconnecting. + // This will abort the splice attempt if it hasn't been signed yet, and restore the channel to a clean state. + // If the splice attempt was signed, it gives us an opportunity to re-exchange signatures on reconnection before + // the forbidden message. It also provides the opportunity for our peer to update their node to get rid of that + // bug and resume normal execution. + val actions = buildList { + add(warn) + add(ChannelAction.Disconnect) + } + Pair(this@Normal, actions) } else -> when (cmd.message) { is UpdateAddHtlc -> when (val result = commitments.receiveAdd(cmd.message)) { @@ -215,7 +215,22 @@ data class Normal( if (result.value.first.changes.localHasChanges()) { actions.add(ChannelAction.Message.SendToSelf(ChannelCommand.Commitment.Sign)) } - Pair(nextState, actions) + // If we're now quiescent, we may send our stfu message. + if (result.value.first.localIsQuiescent()) { + when (spliceStatus) { + is SpliceStatus.QuiescenceRequested -> { + actions.add(ChannelAction.Message.Send(Stfu(channelId, initiator = true))) + Pair(nextState.copy(spliceStatus = SpliceStatus.InitiatorQuiescent(spliceStatus.command)), actions) + } + is SpliceStatus.ReceivedStfu -> { + actions.add(ChannelAction.Message.Send(Stfu(channelId, initiator = false))) + Pair(nextState.copy(spliceStatus = SpliceStatus.NonInitiatorQuiescent), actions) + } + else -> Pair(nextState, actions) + } + } else { + Pair(nextState, actions) + } } } else -> Pair(this@Normal, listOf()) @@ -345,9 +360,99 @@ data class Normal( } } } + + is Stfu -> if (commitments.remoteIsQuiescent()) { + when (spliceStatus) { + is SpliceStatus.None -> { + if (commitments.localIsQuiescent()) { + Pair(this@Normal.copy(spliceStatus = SpliceStatus.NonInitiatorQuiescent), listOf(ChannelAction.Message.Send(Stfu(channelId, initiator = false)))) + } else { + Pair(this@Normal.copy(spliceStatus = SpliceStatus.ReceivedStfu(cmd.message)), emptyList()) + } + } + is SpliceStatus.QuiescenceRequested -> { + // We could keep track of our splice attempt and merge it with the remote splice instead of cancelling it. + // But this is an edge case that should rarely occur, so it's probably not worth the additional complexity. + logger.warning { "our peer initiated quiescence before us, cancelling our splice attempt" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.ReceivedStfu(cmd.message)), emptyList()) + } + is SpliceStatus.InitiatorQuiescent -> { + // if both sides send stfu at the same time, the quiescence initiator is the channel initiator + if (!cmd.message.initiator || commitments.params.localParams.isInitiator) { + if (commitments.isQuiescent()) { + val parentCommitment = commitments.active.first() + val fundingContribution = FundingContributions.computeSpliceContribution( + isInitiator = true, + commitment = parentCommitment, + walletInputs = spliceStatus.command.spliceIn?.walletInputs ?: emptyList(), + localOutputs = spliceStatus.command.spliceOutputs, + targetFeerate = spliceStatus.command.feerate + ) + val commitTxFees = Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) + if (parentCommitment.localCommit.spec.toLocal + fundingContribution.toMilliSatoshi() < parentCommitment.localChannelReserve(commitments.params).max(commitTxFees)) { + logger.warning { "cannot do splice: insufficient funds" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds) + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) + add(ChannelAction.Disconnect) + // pending settlement commands will be replayed on reconnect + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } else if (spliceStatus.command.spliceOut?.scriptPubKey?.let { Helpers.Closing.isValidFinalScriptPubkey(it, allowAnySegwit = true) } == false) { + logger.warning { "cannot do splice: invalid splice-out script" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript) + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceRequest(channelId).message))) + add(ChannelAction.Disconnect) + // pending settlement commands will be replayed on reconnect + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } else { + val spliceInit = SpliceInit( + channelId, + fundingContribution = fundingContribution, + lockTime = currentBlockHeight.toLong(), + feerate = spliceStatus.command.feerate, + fundingPubkey = channelKeys().fundingPubKey(parentCommitment.fundingTxIndex + 1), + pushAmount = spliceStatus.command.pushAmount + ) + logger.info { "initiating splice with local.amount=${spliceInit.fundingContribution} local.push=${spliceInit.pushAmount}" } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Requested(spliceStatus.command, spliceInit)), listOf(ChannelAction.Message.Send(spliceInit))) + } + } else { + logger.warning { "cannot initiate splice, channel not quiescent" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent) + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceNotQuiescent(channelId).message))) + add(ChannelAction.Disconnect) + // pending settlement commands will be replayed on reconnect + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + } else { + logger.warning { "concurrent stfu received and our peer is the channel initiator, cancelling our splice attempt" } + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.NonInitiatorQuiescent), emptyList()) + } + } else -> { + logger.warning { "ignoring duplicate stfu" } + Pair(this@Normal, emptyList()) + } + } + } else { + logger.warning { "our peer sent stfu but is not quiescent" } + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceNotQuiescent(channelId).message))) + add(ChannelAction.Disconnect) + // pending settlement commands will be replayed on reconnect + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + is SpliceInit -> when (spliceStatus) { - is SpliceStatus.None -> - if (commitments.isIdle()) { + is SpliceStatus.None, SpliceStatus.NonInitiatorQuiescent -> + if (commitments.isQuiescent()) { logger.info { "accepting splice with remote.amount=${cmd.message.fundingContribution} remote.push=${cmd.message.pushAmount}" } val parentCommitment = commitments.active.first() val spliceAck = SpliceAck( @@ -380,8 +485,8 @@ data class Normal( val nextState = this@Normal.copy(spliceStatus = SpliceStatus.InProgress(replyTo = null, session, localPushAmount = 0.msat, remotePushAmount = cmd.message.pushAmount, origins = cmd.message.origins)) Pair(nextState, listOf(ChannelAction.Message.Send(spliceAck))) } else { - logger.info { "rejecting splice attempt: channel is not idle" } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceChannelNotIdle(channelId).message)))) + logger.warning { "cannot initiate splice, channel is not quiescent" } + Pair(this@Normal, listOf(ChannelAction.Message.Send(Warning(channelId, InvalidSpliceNotQuiescent(channelId).message)))) } is SpliceStatus.Aborted -> { logger.info { "rejecting splice attempt: our previous tx_abort was not acked" } @@ -574,42 +679,56 @@ data class Normal( is SpliceStatus.Requested -> { logger.info { "our peer rejected our splice request: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii())) - Pair( - this@Normal.copy(spliceStatus = SpliceStatus.None), - listOf(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) - ) + val actions = buildList { + add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) + addAll(endQuiescence()) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } is SpliceStatus.InProgress -> { logger.info { "our peer aborted the splice attempt: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } spliceStatus.replyTo?.complete(ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer(cmd.message.toAscii())) - Pair( - this@Normal.copy(spliceStatus = SpliceStatus.None), - listOf(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) - ) + val actions = buildList { + add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) + addAll(endQuiescence()) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } is SpliceStatus.WaitingForSigs -> { logger.info { "our peer aborted the splice attempt: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } val nextState = this@Normal.copy(spliceStatus = SpliceStatus.None) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message)) - ) + val actions = buildList { + add(ChannelAction.Storage.StoreState(nextState)) + add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) + addAll(endQuiescence()) + } Pair(nextState, actions) } is SpliceStatus.Aborted -> { logger.info { "our peer acked our previous tx_abort" } - Pair( - this@Normal.copy(spliceStatus = SpliceStatus.None), - emptyList() - ) + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), endQuiescence()) } is SpliceStatus.None -> { logger.info { "our peer wants to abort the splice, but we've already negotiated a splice transaction: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } // We ack their tx_abort but we keep monitoring the funding transaction until it's confirmed or double-spent. - Pair( - this@Normal, - listOf(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) - ) + Pair(this@Normal, listOf(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message)))) + } + is SpliceStatus.NonInitiatorQuiescent -> { + logger.info { "our peer aborted their own splice attempt: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } + val actions = buildList { + add(ChannelAction.Message.Send(TxAbort(channelId, SpliceAborted(channelId).message))) + addAll(endQuiescence()) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + is QuiescenceNegotiation -> { + logger.info { "our peer aborted the splice during quiescence negotiation, disconnecting: ascii='${cmd.message.toAscii()}' bin=${cmd.message.data}" } + val actions = buildList { + add(ChannelAction.Message.Send(Warning(channelId, UnexpectedInteractiveTxMessage(channelId, cmd.message).message))) + add(ChannelAction.Disconnect) + // pending settlement commands will be replayed on reconnect + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } } is SpliceLocked -> { @@ -666,6 +785,12 @@ data class Normal( SpliceStatus.None } is SpliceStatus.WaitingForSigs -> spliceStatus + is SpliceStatus.NonInitiatorQuiescent -> SpliceStatus.None + is QuiescenceNegotiation.NonInitiator -> SpliceStatus.None + is QuiescenceNegotiation.Initiator -> { + spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.Disconnected) + SpliceStatus.None + } } // reset the commit_sig batch sigStash = emptyList() @@ -731,6 +856,7 @@ data class Normal( val spliceLocked = SpliceLocked(channelId, action.fundingTx.txId.reversed()) add(ChannelAction.Message.Send(spliceLocked)) } + addAll(endQuiescence()) } return Pair(nextState, actions) } @@ -764,4 +890,10 @@ data class Normal( } } } + + private fun endQuiescence(): List { + val replaySettlements = settlementStash.map { ChannelAction.Message.SendToSelf(it) } + settlementStash = emptyList() + return replaySettlements + } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt index a3bec493f..a5fe34cc1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt @@ -438,6 +438,7 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: val htlcsToReprocess = commitments1.latest.remoteCommit.spec.htlcs.outgoings().filter { !alreadySettled.contains(it.id) } logger.debug { "re-processing signed IN: $htlcsToReprocess" } sendQueue.addAll(htlcsToReprocess.map { ChannelAction.ProcessIncomingHtlc(it) }) + // TODO: do we need to replay settlement commands that came in while quiescent? or are they replayed here? return Pair(commitments1, sendQueue) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 58148e87e..9a441f2a9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -853,6 +853,28 @@ data class ChannelReady( } } +data class Stfu( + override val channelId: ByteVector32, + val initiator: Boolean +) : SetupMessage, HasChannelId { + override val type: Long get() = Stfu.type + + override fun write(out: Output) { + LightningCodecs.writeBytes(channelId, out) + LightningCodecs.writeByte(if (initiator) 1 else 0, out) + } + companion object : LightningMessageReader { + const val type: Long = 2 + + override fun read(input: Input): Stfu { + return Stfu( + ByteVector32(LightningCodecs.bytes(input, 32)), + LightningCodecs.byte(input).toByte() == 1.toByte() + ) + } + } +} + data class SpliceInit( override val channelId: ByteVector32, val fundingContribution: Satoshi, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index 863f7599a..fceb0551b 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -108,19 +108,23 @@ data class LNChannel( // we check that serialization works by checking that deserialize(serialize(state)) == state private fun checkSerialization(state: PersistedChannelState) { - // We don't persist unsigned funding RBF attempts. - fun removeRbfAttempt(state: PersistedChannelState): PersistedChannelState = when (state) { + // We don't persist unsigned funding RBF or splice attempts. + fun removeTemporaryStatuses(state: PersistedChannelState): PersistedChannelState = when (state) { is WaitForFundingConfirmed -> when (state.rbfStatus) { is RbfStatus.WaitingForSigs -> state else -> state.copy(rbfStatus = RbfStatus.None) } + is Normal -> when (state.spliceStatus) { + is SpliceStatus.WaitingForSigs -> state + else -> state.copy(spliceStatus = SpliceStatus.None) + } else -> state } val serialized = Serialization.serialize(state) val deserialized = Serialization.deserialize(serialized).value - assertEquals(removeRbfAttempt(state), deserialized, "serialization error") + assertEquals(removeTemporaryStatuses(state), deserialized, "serialization error") } private fun checkSerialization(actions: List) { @@ -480,7 +484,7 @@ object TestsHelper { val (receiver3, rActions3) = receiveCommitSigs(receiver2, commitSigs2) val revokeAndAck2 = rActions3.findOutgoingMessage() - val (sender4, _) = sender3.process(ChannelCommand.MessageReceived(revokeAndAck2)) + val (sender4, sActions4) = sender3.process(ChannelCommand.MessageReceived(revokeAndAck2)) assertIs>(sender4) assertIs>(receiver3) @@ -489,7 +493,7 @@ object TestsHelper { assertEquals(rCommitIndex + 2, receiver3.commitments.localCommitIndex) assertEquals(sCommitIndex + 1, receiver3.commitments.remoteCommitIndex) - return Triple(sender4, receiver3, rActions3) + return Triple(sender4, receiver3, sActions4 + rActions3) } else { assertIs>(sender2) assertEquals(sCommitIndex + 1, sender2.commitments.localCommitIndex) @@ -497,7 +501,7 @@ object TestsHelper { assertEquals(rCommitIndex + 1, receiver2.commitments.localCommitIndex) assertEquals(sCommitIndex + 1, receiver2.commitments.remoteCommitIndex) - return Triple(sender2, receiver2, rActions2) + return Triple(sender2, receiver2, sActions2 + rActions2) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt new file mode 100644 index 000000000..dfd0542cd --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -0,0 +1,579 @@ +package fr.acinq.lightning.channel.states + +import fr.acinq.bitcoin.* +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Feature +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.blockchain.WatchConfirmed +import fr.acinq.lightning.blockchain.WatchSpent +import fr.acinq.lightning.blockchain.electrum.WalletState +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.* +import fr.acinq.lightning.channel.TestsHelper.crossSign +import fr.acinq.lightning.channel.TestsHelper.fulfillHtlc +import fr.acinq.lightning.channel.TestsHelper.htlcSuccessTxs +import fr.acinq.lightning.channel.TestsHelper.reachNormal +import fr.acinq.lightning.crypto.KeyManager +import fr.acinq.lightning.tests.TestConstants +import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toMilliSatoshi +import fr.acinq.lightning.wire.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.* + +class QuiescenceTestsCommon : LightningTestSuite() { + + @Test + fun `send stfu after pending local changes have been added`() { + // we have an unsigned htlc in our local changes + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val amounts = listOf(50_000.sat) + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, amounts)), + spliceOut = null, + feerate = FeeratePerKw(253.sat) + ) + val (alice2, actionsAlice1) = alice1.process(cmd) + assertEquals(actionsAlice1.size, 0) + assertIs>(alice2) + val (_, _, actionsAlice2) = crossSign(alice2, bob1) + actionsAlice2.findOutgoingMessage() + } + + @Test + fun `recv stfu when there are pending local changes` () { + val (alice, bob) = init() + val (alice1, bob1, stfu) = initiateQuiescence(alice, bob, sendInitialStfu = false) + // we're holding the stfu from alice so that bob can add a pending local change + val (nodes2, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob1, alice1) + val (bob2, alice2) = nodes2 + // bob will not reply to alice's stfu until bob has no pending local commitment changes + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(stfu)) + assertEquals(actionsBob3.size, 0) + assertIs>(alice2) + assertIs>(bob3) + val (bob4, alice3, actionsBob4) = crossSign(bob3, alice2) + val stfu2 = actionsBob4.findOutgoingMessage() + bob4.state.commitments.isQuiescent() + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(stfu2)) + // when both nodes are quiescent, alice will start the splice + val spliceInit = actionsAlice4.findOutgoingMessage() + val (_, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsBob5.findOutgoingMessage() + alice4.process(ChannelCommand.MessageReceived(spliceAck)) + } + + @Test + fun `recv forbidden non-settlement commands while initiator awaiting stfu from remote` () { + val (alice, bob) = init() + // initiator should reject commands that change the commitment once it became quiescent + val cmds = listOf( + ChannelCommand.Htlc.Add(1_000_000.msat, Lightning.randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(alice.currentBlockHeight.toLong()), TestConstants.emptyOnionPacket, UUID.randomUUID()), + ChannelCommand.Commitment.UpdateFee(FeeratePerKw(100.sat)), + ChannelCommand.Close.MutualClose(null, null), + ) + val (alice1, _, _) = initiateQuiescence(alice, bob, sendInitialStfu = false) + cmds.forEach { + safeSend(alice1, listOf(it)).second.findCommandError() + } + } + + @Test + fun `recv forbidden non-settlement commands while quiescent` () { + val (alice, bob) = init() + // both should reject commands that change the commitment while quiescent + val cmds = listOf( + ChannelCommand.Htlc.Add(1_000_000.msat, Lightning.randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(alice.currentBlockHeight.toLong()), TestConstants.emptyOnionPacket, UUID.randomUUID()), + ChannelCommand.Commitment.UpdateFee(FeeratePerKw(100.sat)), + ChannelCommand.Close.MutualClose(null, null) + ) + val (alice1, bob1, _) = initiateQuiescence(alice, bob, sendInitialStfu = true) + cmds.forEach { + safeSend(alice1, listOf(it)).second.findCommandError() + } + cmds.forEach { + safeSend(bob1, listOf(it)).second.findCommandError() + } + safeSend(bob, cmds) + } + + @Test + fun `recv CMD_FULFILL_HTLC while initiator awaiting stfu from remote` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FulfillHtlc, sendInitialStfu = false) + } + + @Test + fun `recv CMD_FAIL_HTLC while initiator awaiting stfu from remote` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FailHtlc, sendInitialStfu = false) + } + + @Test + fun `recv CMD_FULFILL_HTLC while initiator awaiting stfu from remote and channel disconnects` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FulfillHtlc, sendInitialStfu = false, resetConnection = true) + } + + @Test + fun `recv CMD_FAIL_HTLC while initiator awaiting stfu from remote and channel disconnects` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FailHtlc, sendInitialStfu = false, resetConnection = true) + } + + @Test + fun `recv CMD_FULFILL_HTLC while quiescent` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FulfillHtlc, sendInitialStfu = true) + } + + @Test + fun `recv CMD_FAIL_HTLC while quiescent` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FailHtlc, sendInitialStfu = true) + } + + @Test + fun `recv CMD_FULFILL_HTLC while quiescent and channel disconnects` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FulfillHtlc, sendInitialStfu = true, resetConnection = true) + } + + @Test + fun `recv CMD_FAIL_HTLC while quiescent and channel disconnects` () { + val (alice, bob) = init() + receiveSettlementCommand(alice, bob, FailHtlc, sendInitialStfu = true, resetConnection = true) + } + + @Test + fun `recv second stfu while non-initiator waiting for local commitment to be signed` () { + val (alice, bob) = init() + val (_, bob1, stfu) = initiateQuiescence(alice, bob, sendInitialStfu = false) + TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(stfu)) + // second stfu to bob is ignored + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(stfu)) + assertEquals(actionsBob3.size, 0) + } + + @Test + fun `recv Shutdown message before initiator receives stfu from remote` () { + val (alice, bob) = init() + val (alice1, _, _) = initiateQuiescence(alice, bob, sendInitialStfu = false) + val forbiddenMsg = Shutdown(alice.channelId, bob.commitments.params.localParams.defaultFinalScriptPubKey) + // handle Shutdown normally + alice1.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + } + + @Test + fun `recv forbidden Shutdown message while quiescent` () { + val (alice, bob) = init() + val (alice1, bob1, _) = initiateQuiescence(alice, bob, sendInitialStfu = true) + val forbiddenMsg = Shutdown(alice.channelId, bob.commitments.params.localParams.defaultFinalScriptPubKey) + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + val (_, actionsAlice1) = alice1.process(ChannelCommand.MessageReceived(forbiddenMsg)) + actionsAlice1.findOutgoingMessage() + actionsAlice1.has() + val (_, actionsBob1) = bob1.process(ChannelCommand.MessageReceived(forbiddenMsg)) + actionsBob1.findOutgoingMessage() + actionsBob1.has() + } + + @Test + fun `recv forbidden UpdateFulfillHtlc message while quiescent` () { + val (alice, bob) = init() + val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, bob3, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + val forbiddenMsg = UpdateFulfillHtlc(bob3.channelId, htlc.id, preimage) + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + alice3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + bob3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + // TODO: assert(bob2relayer.expectMsgType[RES_ADD_SETTLED[_, HtlcResult.RemoteFulfill]].result.paymentPreimage == preimage) + } + + @Test + fun `recv forbidden UpdateFailHtlc message while quiescent` () { + val (alice, bob) = init() + val (nodes1, _, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, bob3, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + val forbiddenMsg = UpdateFailHtlc(bob3.channelId, htlc.id, Lightning.randomBytes32()) + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + alice3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + bob3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + // TODO: assert(bob2relayer.expectMsgType[RES_ADD_SETTLED[_, HtlcResult.RemoteFulfill]].result.paymentPreimage == preimage) + } + + @Test + fun `recv forbidden UpdateFee message while quiescent` () { + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, bob3, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + val forbiddenMsg = UpdateFee(bob3.channelId, FeeratePerKw(500.sat)) + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + alice3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + bob3.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + // TODO: assert(bob2relayer.expectMsgType[RES_ADD_SETTLED[_, HtlcResult.RemoteFulfill]].result.paymentPreimage == preimage + } + + @Test + fun `recv forbidden UpdateAddHtlc message while quiescent` () { + val (alice, bob) = init() + val (alice1, bob1, _) = initiateQuiescence(alice, bob, sendInitialStfu = true) + // have to build a htlc manually because eclair would refuse to accept this command as it's forbidden + val forbiddenMsg = UpdateAddHtlc(Lightning.randomBytes32(), id = 5656, amountMsat = 50000000.msat, cltvExpiry = CltvExpiryDelta(144).toCltvExpiry(alice.currentBlockHeight.toLong()), paymentHash = Lightning.randomBytes32(), onionRoutingPacket = TestConstants.emptyOnionPacket) + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + alice1.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + bob1.process(ChannelCommand.MessageReceived(forbiddenMsg)).second.findOutgoingMessage() + } + + @Test + fun `recv stfu from splice initiator that is not quiescent` () { + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(Stfu(alice1.channelId, initiator = true))) + actionsBob2.findOutgoingMessage() + // we should disconnect after giving alice time to receive the warning + actionsBob2.find() + } + + @Test + fun `recv stfu from splice non-initiator that is not quiescent` () { + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (alice2, bob2, _) = initiateQuiescence(alice1, bob1, sendInitialStfu = false) + val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(Stfu(bob2.channelId, initiator = false))) + actionsAlice3.findOutgoingMessage() + // we should disconnect after giving bob time to receive the warning + actionsAlice3.find() + } + + @Test + fun `initiate quiescence concurrently with no pending changes` () { + val (alice, bob) = init() + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, listOf(500_000.sat))), + spliceOut = null, + feerate = FeeratePerKw(253.sat) + ) + val (alice1, actionsAlice1) = alice.process(cmd) + val stfuAlice = actionsAlice1.findOutgoingMessage() + val (bob1, actionsBob1) = bob.process(cmd) + val stfuBob = actionsBob1.findOutgoingMessage() + val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(stfuBob)) + val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) + actionsAlice2.findOutgoingMessage() + assertIs>(bob2) + assertIs(bob2.state.spliceStatus) + runBlocking { + withTimeout(100) { + assertIs(cmd.replyTo.await()) + } + } + } + + @Test + fun `initiate quiescence concurrently with pending changes on initiator side` () { + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, listOf(500_000.sat))), + spliceOut = null, + feerate = FeeratePerKw(253.sat) + ) + val (alice2, actionsAlice2) = alice1.process(cmd) + assertEquals(actionsAlice2.size, 0) // alice isn't quiescent yet + val (bob2, actionsBob2) = bob1.process(cmd) + val stfuBob = actionsBob2.findOutgoingMessage() + val (alice3, _) = alice2.process(ChannelCommand.MessageReceived(stfuBob)) + assertIs>(alice3) + assertIs>(bob2) + val (alice4, bob3, aliceActions4) = crossSign(alice3, bob2) + val stfuAlice = aliceActions4.findOutgoingMessage() + val (_, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(alice4) + assertIs(alice4.state.spliceStatus) + runBlocking { + withTimeout(100) { + assertIs(cmd.replyTo.await()) + } + } + actionsBob4.findOutgoingMessage() + } + + @Test + fun `initiate quiescence concurrently with pending changes on non-initiator side` () { + val (alice, bob) = init() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(alice.staticParams.nodeParams.keyManager, listOf(500_000.sat))), + spliceOut = null, + feerate = FeeratePerKw(253.sat) + ) + val (alice2, actionsAlice2) = alice1.process(cmd) + val stfuAlice = actionsAlice2.findOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(cmd) + assertEquals(actionsBob2.size, 0) // bob isn't quiescent yet + val (bob3, _) = bob2.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(alice2) + assertIs>(bob3) + val (bob4, alice3, bobActions4) = crossSign(bob3, alice2) + val stfuBob = bobActions4.findOutgoingMessage() + val (_, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(stfuBob)) + assertIs>(bob4) + assertIs(bob4.state.spliceStatus) + runBlocking { + withTimeout(100) { + assertIs(cmd.replyTo.await()) + } + } + actionsAlice4.findOutgoingMessage() + } + + @Test + fun `incoming htlc timeout during quiescence negotiation` () { + val (alice, bob) = init() + val (nodes1, _, add) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, _, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + assertIs>(alice3) + + // the HTLC timeout from bob is near, alice needs to close the channel to avoid an on-chain race from stashed preimage + val (alice4, aliceActions4) = run { + val tmp = alice3.copy(ctx = alice3.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt())) + tmp.process(ChannelCommand.Commitment.CheckHtlcTimeout) + } + + assertIs>(alice4) + val lcp = alice4.state.localCommitPublished!! + aliceActions4.hasPublishTx(lcp.commitTx) + aliceActions4.hasPublishTx(lcp.htlcTxs.values.first()!!.tx) + aliceActions4.hasWatchConfirmed(lcp.commitTx.txid) + aliceActions4.hasWatchConfirmed(lcp.claimMainDelayedOutputTx!!.tx.txid) + aliceActions4.hasOutgoingMessage() + aliceActions4.has() + aliceActions4.has() + } + + @Test + fun `incoming htlc timeout during quiescence negotiation with pending preimage` () { + val (alice, bob) = init() + val (nodes1, preimage, add) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, _, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + + // alice receives the fulfill for htlc, which is stashed because the channel is quiescent + val fulfillHtlc = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage) + val (alice4, actionsAlice4) = alice3.process(fulfillHtlc) + assertEquals(actionsAlice4.size, 0) + + // the HTLC timeout from bob is near, alice needs to close the channel to avoid an on-chain race from stashed preimage + val (alice5, actionsAlice5) = run { + val tmp = alice4.copy(ctx = alice4.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt())) + tmp.process(ChannelCommand.Commitment.CheckHtlcTimeout) + } + + // alice publishes a first set of force-close transactions + assertIs(alice5.state) + + val lcp = alice5.state.localCommitPublished!! + actionsAlice5.hasPublishTx(lcp.commitTx) + actionsAlice5.hasPublishTx(lcp.htlcTxs.values.first()!!.tx) + actionsAlice5.hasWatchConfirmed(lcp.commitTx.txid) + actionsAlice5.hasWatchConfirmed(lcp.claimMainDelayedOutputTx!!.tx.txid) + actionsAlice5.hasOutgoingMessage() + actionsAlice5.has() + actionsAlice5.has() + + // when transitioning to the closing state, alice replays stashed HTLC settlements + /* TODO: + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == commitTx.txid) + bob2blockchain.expectMsgType[PublishTx] // main delayed + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcSuccessTx.txid) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) + bob2blockchain.expectMsgType[WatchTxConfirmed] // main delayed + bob2blockchain.expectMsgType[WatchOutputSpent] // htlc output + bob2blockchain.expectNoMessage(100 millis) + + channelUpdateListener.expectMsgType[LocalChannelDown] + */ + } + + companion object { + fun init(): Pair, LNChannel> { + // NB: we disable channel backups to ensure Bob sends his channel_reestablish on reconnection. + val (alice, bob, _) = reachNormal(bobFeatures = TestConstants.Bob.nodeParams.features.remove(Feature.ChannelBackupClient)) + return Pair(alice, bob) + } + + fun disconnect(alice: LNChannel, bob: LNChannel): Triple, LNChannel, Pair> { + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Disconnected) + val (bob1, actionsBob1) = bob.process(ChannelCommand.Disconnected) + assertIs(alice1.state) + assertTrue(actionsAlice1.isEmpty()) + assertIs(bob1.state) + assertTrue(actionsBob1.isEmpty()) + + val aliceInit = Init(alice1.commitments.params.localParams.features) + val bobInit = Init(bob1.commitments.params.localParams.features) + assertFalse(bob1.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupClient)) + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.Connected(aliceInit, bobInit)) + assertIs>(alice2) + val channelReestablishA = actionsAlice2.findOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(ChannelCommand.Connected(bobInit, aliceInit)) + assertIs>(bob2) + val channelReestablishB = actionsBob2.findOutgoingMessage() + return Triple(alice2, bob2, Pair(channelReestablishA, channelReestablishB)) + } + } + + private fun safeSend(r: LNChannel, cmds: List): Pair, List> { + var receiver: LNChannel = r + val actions = buildList { + cmds.forEach { + val (r1, a) = receiver.process(it) + assertIs>(r1) + // settlement commands will be stashed by the channel and replayed on disconnect or when the splice ends + cmds.forEach { c -> + if (c is ChannelCommand.Htlc.Settlement) { + assertContains(r1.state.settlementStash, c) + } + } + receiver = r1 + addAll(a) + } + } + return Pair(receiver, actions) + } + + private fun initiateQuiescence(sender: LNChannel, receiver: LNChannel, sendInitialStfu: Boolean): Triple, LNChannel, LightningMessage> { + val cmd = ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(sender.staticParams.nodeParams.keyManager, listOf(500_000.sat))), + spliceOut = ChannelCommand.Commitment.Splice.Request.SpliceOut(100_000.sat, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()), + feerate = FeeratePerKw(253.sat) + ) + val (sender1, sActions1) = sender.process(cmd) + val stfu = sActions1.findOutgoingMessage() + if (!sendInitialStfu) { + // only alice is quiescent, we're holding the first stfu to pause the splice + } else { + val (receiver1, rActions1) = receiver.process(ChannelCommand.MessageReceived(stfu)) + val stfu2 = rActions1.findOutgoingMessage() + val (sender2, sActions2) = sender1.process(ChannelCommand.MessageReceived(stfu2)) + val spliceInit = sActions2.findOutgoingMessage() + // both alice and bob are quiescent, we're holding the splice-init to pause the splice + assertIs>(sender2) + assertIs>(receiver1) + return Triple(sender2, receiver1, spliceInit) + } + assertIs>(sender1) + assertIs>(receiver) + return Triple(sender1, receiver, stfu) + } + + sealed class SettlementCommandEnum + object FulfillHtlc : SettlementCommandEnum() + object FailHtlc : SettlementCommandEnum() + + private fun receiveSettlementCommand(alice: LNChannel, bob: LNChannel, c: SettlementCommandEnum, sendInitialStfu: Boolean, resetConnection: Boolean = false): Unit { + val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val cmd = when (c) { + FulfillHtlc -> ChannelCommand.Htlc.Settlement.Fulfill(htlc.id, preimage) + FailHtlc -> ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + } + assertIs>(alice1) + assertIs>(bob1) + val (bob2, alice2, _) = crossSign(bob1, alice1) + val (alice3, bob3, spliceInit) = initiateQuiescence(alice2, bob2, sendInitialStfu) + assertIs>(bob3) + // alice does not forward settlement command to bob because alice receives it from Peer *after* splice request + val (alice4, actionsAlice4) = alice3.process(cmd) + assertEquals(actionsAlice4.size, 0) + assertIs>(alice4) + val spliceCommand = when (alice4.state.spliceStatus) { + is SpliceStatus.InitiatorQuiescent -> (alice4.state.spliceStatus as SpliceStatus.InitiatorQuiescent).command + is SpliceStatus.Requested -> (alice4.state.spliceStatus as SpliceStatus.Requested).command + else -> null + } + + val units = if (resetConnection) { + // both alice and bob leave quiescence when the channel is disconnected + disconnect(alice3, bob3) + } + else if (sendInitialStfu) { + // alice sends splice-init to bob + val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(spliceInit)) + actionsBob4.findOutgoingMessage() + // both alice and bob leave quiescence when the splice aborts + assertIs>(alice3) + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(TxAbort(alice4.state.channelId, "deadbeef"))) + val abort = actionsAlice5.findOutgoingMessage() + val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(abort)) + actionsBob5.findOutgoingMessage() + // alice sends pending settlement after quiescence ends + assertContains(actionsAlice5, ChannelAction.Message.SendToSelf(cmd)) + Triple(alice5, bob5, null) + } else { + // alice will eventually disconnect if bob does not send stfu. + disconnect(alice3, bob3) + } + + val (alice6, _, reestablish) = units + if (resetConnection || !sendInitialStfu) { + // any failure during quiescence will cause alice to disconnect + assertIs>(alice6) + val state1 = alice6.state.state + assertIs(state1) + assertNotNull(spliceCommand) + runBlocking { + withTimeout(100) { + assertIs(spliceCommand.replyTo.await()) + } + } + assertTrue(state1.spliceStatus is SpliceStatus.None) + assertNotNull(reestablish) + + // after reconnecting, Alice resends ProcessIncomingHtlc to Peer + val (_, actionsAlice7) = alice6.process(ChannelCommand.MessageReceived(reestablish.second)) + val processIncomingHtlc = actionsAlice7.find() + assertEquals(processIncomingHtlc.add, htlc) + } + } + + private fun createWalletWithFunds(keyManager: KeyManager, amounts: List): List { + val script = keyManager.swapInOnChainWallet.pubkeyScript + return amounts.map { amount -> + val txIn = listOf(TxIn(OutPoint(Lightning.randomBytes32(), 2), 0)) + val txOut = listOf(TxOut(amount, script), TxOut(150.sat, Script.pay2wpkh(Lightning.randomKey() + .publicKey()))) + val parentTx = Transaction(2, txIn, txOut, 0) + WalletState.Utxo(parentTx, 0, 42) + } + } + +} diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 722d19558..5ea24b259 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -103,9 +103,8 @@ class SpliceTestsCommon : LightningTestSuite() { @Test fun `reject splice_init`() { val cmd = createSpliceOutRequest(25_000.sat) - val (alice, _) = reachNormal() - val (alice1, actionsAlice1) = alice.process(cmd) - actionsAlice1.hasOutgoingMessage() + val (alice, bob) = reachNormal() + val (alice1, _, _) = reachQuiescent(cmd, alice, bob) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "thanks but no thanks"))) assertIs(alice2.state) assertEquals(alice2.state.spliceStatus, SpliceStatus.None) @@ -117,8 +116,8 @@ class SpliceTestsCommon : LightningTestSuite() { fun `reject splice_ack`() { val cmd = createSpliceOutRequest(25_000.sat) val (alice, bob) = reachNormal() - val (_, actionsAlice1) = alice.process(cmd) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(actionsAlice1.hasOutgoingMessage())) + val (_, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) actionsBob1.hasOutgoingMessage() val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, "changed my mind"))) assertIs(bob2.state) @@ -131,8 +130,8 @@ class SpliceTestsCommon : LightningTestSuite() { fun `abort before tx_complete`() { val cmd = createSpliceOutRequest(20_000.sat) val (alice, bob) = reachNormal() - val (alice1, actionsAlice1) = alice.process(cmd) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(actionsAlice1.findOutgoingMessage())) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(actionsBob2.findOutgoingMessage())) @@ -157,8 +156,8 @@ class SpliceTestsCommon : LightningTestSuite() { fun `abort after tx_complete`() { val cmd = createSpliceOutRequest(31_000.sat) val (alice, bob) = reachNormal() - val (alice1, actionsAlice1) = alice.process(cmd) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(actionsAlice1.findOutgoingMessage())) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(actionsBob2.findOutgoingMessage())) @@ -191,8 +190,8 @@ class SpliceTestsCommon : LightningTestSuite() { fun `abort after tx_complete then receive commit_sig`() { val cmd = createSpliceOutRequest(50_000.sat) val (alice, bob) = reachNormal() - val (alice1, actionsAlice1) = alice.process(cmd) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(actionsAlice1.findOutgoingMessage())) + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(actionsBob1.findOutgoingMessage())) val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(actionsAlice2.findOutgoingMessage())) val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(actionsBob2.findOutgoingMessage())) @@ -946,11 +945,10 @@ class SpliceTestsCommon : LightningTestSuite() { val parentCommitment = alice.commitments.active.first() val cmd = createSpliceOutRequest(amount) // Negotiate a splice transaction where Alice is the only contributor. - val (alice1, actionsAlice1) = alice.process(cmd) - val spliceInit = actionsAlice1.findOutgoingMessage() + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) // Alice takes more than the spliced out amount from her local balance because she must pay on-chain fees. assertTrue(-amount - 500.sat < spliceInit.fundingContribution && spliceInit.fundingContribution < -amount) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(spliceInit)) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val spliceAck = actionsBob1.findOutgoingMessage() assertEquals(spliceAck.fundingContribution, 0.sat) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) @@ -981,11 +979,10 @@ class SpliceTestsCommon : LightningTestSuite() { ) // Negotiate a splice transaction where Alice is the only contributor. - val (alice1, actionsAlice1) = alice.process(cmd) - val spliceInit = actionsAlice1.findOutgoingMessage() + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) // Alice adds slightly less than her wallet amount because she must pay on-chain fees. assertTrue(amounts.sum() - 500.sat < spliceInit.fundingContribution && spliceInit.fundingContribution < amounts.sum()) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(spliceInit)) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val spliceAck = actionsBob1.findOutgoingMessage() assertEquals(spliceAck.fundingContribution, 0.sat) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) @@ -1020,11 +1017,10 @@ class SpliceTestsCommon : LightningTestSuite() { ) // Negotiate a splice transaction with no contribution. - val (alice1, actionsAlice1) = alice.process(cmd) - val spliceInit = actionsAlice1.findOutgoingMessage() + val (alice1, bobQuiescent, spliceInit) = reachQuiescent(cmd, alice, bob) // Alice's contribution is negative: that amount goes to on-chain fees. assertTrue(spliceInit.fundingContribution < 0.sat) - val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(spliceInit)) + val (bob1, actionsBob1) = bobQuiescent.process(ChannelCommand.MessageReceived(spliceInit)) val spliceAck = actionsBob1.findOutgoingMessage() assertEquals(spliceAck.fundingContribution, 0.sat) val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(spliceAck)) @@ -1272,6 +1268,17 @@ class SpliceTestsCommon : LightningTestSuite() { actionsAlice6.has() actionsAlice6.has() } + + private fun reachQuiescent(cmd: ChannelCommand.Commitment.Splice.Request, alice: LNChannel, bob: LNChannel) : Triple, LNChannel, SpliceInit> { + // Negotiate quiescence with no pending htlcs + val (alice1, actionsAlice1) = alice.process(cmd) + val aliceStfu = actionsAlice1.findOutgoingMessage() + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(aliceStfu)) + val bobStfu = actionsBob1.findOutgoingMessage() + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(bobStfu)) + val spliceInit = actionsAlice2.findOutgoingMessage() + return Triple(alice2, bob1, spliceInit) + } } }