From 4d07bcaa5e38a85957f9913c78daa26393639577 Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Wed, 1 Nov 2023 15:35:19 +0100 Subject: [PATCH] Add quiescence negotiation --- .../kotlin/fr/acinq/lightning/Features.kt | 8 + .../kotlin/fr/acinq/lightning/NodeParams.kt | 1 + .../acinq/lightning/channel/ChannelAction.kt | 2 + .../acinq/lightning/channel/ChannelCommand.kt | 12 +- .../lightning/channel/ChannelException.kt | 3 +- .../fr/acinq/lightning/channel/Commitments.kt | 21 +- .../acinq/lightning/channel/InteractiveTx.kt | 31 +- .../acinq/lightning/channel/states/Normal.kt | 253 ++++++-- .../acinq/lightning/channel/states/Syncing.kt | 14 +- .../kotlin/fr/acinq/lightning/io/Peer.kt | 5 + .../acinq/lightning/wire/LightningMessages.kt | 23 + .../fr/acinq/lightning/channel/TestsHelper.kt | 10 +- .../channel/states/QuiescenceTestsCommon.kt | 590 ++++++++++++++++++ .../channel/states/SpliceTestsCommon.kt | 47 +- .../channel/states/SyncingTestsCommon.kt | 16 + .../acinq/lightning/io/peer/ConnectionTest.kt | 2 +- 16 files changed, 924 insertions(+), 114 deletions(-) create mode 100644 src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt diff --git a/src/commonMain/kotlin/fr/acinq/lightning/Features.kt b/src/commonMain/kotlin/fr/acinq/lightning/Features.kt index 37b5ab3e8..7d2fe24b0 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, FeatureScope.Node) + } + } @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/ChannelAction.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt index 518971e47..f2fbb6b70 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelAction.kt @@ -128,5 +128,7 @@ sealed class ChannelAction { } data class EmitEvent(val event: ChannelEvents) : ChannelAction() + + object Disconnect : ChannelAction() // @formatter:on } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 5791eb484..43a723d3a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -61,10 +61,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() @@ -80,7 +81,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() { @@ -108,7 +109,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() @@ -121,7 +123,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 970ce5967..1bd383b09 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -41,7 +41,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") @@ -85,4 +85,5 @@ 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 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 41a3771b3..234167c6d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -252,8 +252,6 @@ data class Commitment( return hasNoPendingHtlcs() && hasNoPendingFeeUpdate } - fun isIdle(changes: CommitmentChanges): Boolean = hasNoPendingHtlcs() && changes.localChanges.all.isEmpty() && changes.remoteChanges.all.isEmpty() - fun timedOutOutgoingHtlcs(blockHeight: Long): Set { fun expired(add: UpdateAddHtlc) = blockHeight >= add.cltvExpiry.toLong() @@ -596,8 +594,10 @@ 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 timedOutOutgoingHtlcs(currentHeight: Long): Set = active.first().timedOutOutgoingHtlcs(currentHeight) fun almostTimedOutIncomingHtlcs(currentHeight: Long, fulfillSafety: CltvExpiryDelta): Set = active.first().almostTimedOutIncomingHtlcs(currentHeight, fulfillSafety, changes) @@ -605,6 +605,21 @@ data class Commitments( fun getIncomingHtlcCrossSigned(htlcId: Long): UpdateAddHtlc? = active.first().getIncomingHtlcCrossSigned(htlcId) // @formatter:on + /** + * Whenever we're not sure the `IncomingPaymentHandler` has received our previous `ChannelAction.ProcessIncomingHtlcs`, + * or when we may have ignored the responses from the `IncomingPaymentHandler` (eg. while quiescent or disconnected), + * we need to reprocess those incoming HTLCs. + */ + fun reprocessIncomingHtlcs(): List { + // We are interested in incoming HTLCs, that have been *cross-signed* (otherwise they wouldn't have been forwarded to the payment handler). + // They signed it first, so the HTLC will first appear in our commitment tx, and later on in their commitment when we subsequently sign it. + // That's why we need to look in *their* commitment with direction=OUT. + // + // We also need to filter out htlcs that we already settled and signed (the settlement messages are being retransmitted). + val alreadySettled = changes.localChanges.signed.filterIsInstance().map { it.id }.toSet() + return latest.remoteCommit.spec.htlcs.outgoings().filter { !alreadySettled.contains(it.id) }.map { ChannelAction.ProcessIncomingHtlc(it) } + } + fun sendAdd(cmd: ChannelCommand.Htlc.Add, paymentId: UUID, blockHeight: Long): Either> { val maxExpiry = Channel.MAX_CLTV_EXPIRY_DELTA.toCltvExpiry(blockHeight) // we don't want to use too high a refund timeout, because our funds will be locked during that time if the payment is never fulfilled diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index f68fa92a7..3bf367aa4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -897,10 +897,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/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index a0e145cef..b0713f22e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -30,9 +30,18 @@ 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) { - val error = ForbiddenDuringSplice(channelId, cmd::class.simpleName) - return handleCommandError(cmd, error, channelUpdate) + val forbiddenPreSplice = cmd is ChannelCommand.ForbiddenDuringQuiescence && spliceStatus is QuiescenceNegotiation + val forbiddenDuringSplice = cmd is ChannelCommand.ForbiddenDuringSplice && spliceStatus is QuiescentSpliceStatus + if (forbiddenPreSplice || forbiddenDuringSplice) { + return when (cmd) { + is ChannelCommand.Htlc.Settlement -> { + // Htlc settlement commands are ignored and will be replayed when the splice completes. + // This could create issues if we're keeping htlcs that should be settled pending for too long, as they could timeout. + logger.warning { "ignoring ${cmd::class.simpleName} for htlc #${cmd.id} during splice: will be replayed once splice is complete" } + Pair(this@Normal, listOf()) + } + else -> handleCommandError(cmd, ForbiddenDuringSplice(channelId, cmd::class.simpleName), channelUpdate) + } } return when (cmd) { is ChannelCommand.Htlc.Add -> { @@ -99,39 +108,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 -> { @@ -141,11 +121,18 @@ 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 - val error = ForbiddenDuringSplice(channelId, cmd.message::class.simpleName) - handleLocalError(cmd, error) + cmd.message is ForbiddenMessageDuringSplice && spliceStatus is QuiescentSpliceStatus -> { + logger.warning { "received forbidden message ${cmd::class.simpleName} during splicing with status ${spliceStatus::class.simpleName}" } + // 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(ChannelAction.Message.Send(Warning(channelId, ForbiddenDuringSplice(channelId, cmd.message::class.simpleName).message))) + add(ChannelAction.Disconnect) + } + Pair(this@Normal, actions) } else -> when (cmd.message) { is UpdateAddHtlc -> when (val result = commitments.receiveAdd(cmd.message)) { @@ -209,13 +196,25 @@ data class Normal( is List -> when (val result = commitments.receiveCommit(sigs, channelKeys(), logger)) { is Either.Left -> handleLocalError(cmd, result.value) is Either.Right -> { - val nextState = this@Normal.copy(commitments = result.value.first) + val commitments1 = result.value.first + val spliceStatus1 = when { + spliceStatus is SpliceStatus.QuiescenceRequested && commitments1.localIsQuiescent() -> SpliceStatus.InitiatorQuiescent(spliceStatus.command) + spliceStatus is SpliceStatus.ReceivedStfu && commitments1.localIsQuiescent() -> SpliceStatus.NonInitiatorQuiescent + else -> spliceStatus + } + val nextState = this@Normal.copy(commitments = commitments1, spliceStatus = spliceStatus1) val actions = mutableListOf() actions.add(ChannelAction.Storage.StoreState(nextState)) actions.add(ChannelAction.Message.Send(result.value.second)) - if (result.value.first.changes.localHasChanges()) { + if (commitments1.changes.localHasChanges()) { actions.add(ChannelAction.Message.SendToSelf(ChannelCommand.Commitment.Sign)) } + // If we're now quiescent, we may send our stfu message. + when { + spliceStatus is SpliceStatus.QuiescenceRequested && commitments1.localIsQuiescent() -> actions.add(ChannelAction.Message.Send(Stfu(channelId, initiator = true))) + spliceStatus is SpliceStatus.ReceivedStfu && commitments1.localIsQuiescent() -> actions.add(ChannelAction.Message.Send(Stfu(channelId, initiator = false))) + else -> {} + } Pair(nextState, actions) } } @@ -346,9 +345,107 @@ data class Normal( } } } + is Stfu -> when { + localShutdown != null -> { + logger.warning { "our peer sent stfu but we sent shutdown first" } + // We don't need to do anything, they should accept our shutdown. + Pair(this@Normal, listOf()) + } + !commitments.remoteIsQuiescent() -> { + 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) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) + } + else -> 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 = when { + commitments.params.localParams.isInitiator -> Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) + else -> 0.sat + } + 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) + } + 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) + } + 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) + } + 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()) + } + } + } is SpliceInit -> when (spliceStatus) { - is SpliceStatus.None -> - if (commitments.isIdle()) { + is SpliceStatus.None -> { + logger.warning { "rejecting splice attempt: quiescence not negotiated" } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceNotQuiescent(channelId).message)))) + } + is 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( @@ -381,8 +478,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 { "rejecting splice attempt: channel is not quiescent" } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.Aborted), listOf(ChannelAction.Message.Send(TxAbort(channelId, InvalidSpliceNotQuiescent(channelId).message)))) } is SpliceStatus.Aborted -> { logger.info { "rejecting splice attempt: our previous tx_abort was not acked" } @@ -578,42 +675,55 @@ 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) + } + Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } } is SpliceLocked -> { @@ -670,6 +780,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() @@ -735,6 +851,7 @@ data class Normal( val spliceLocked = SpliceLocked(channelId, action.fundingTx.txId) add(ChannelAction.Message.Send(spliceLocked)) } + addAll(endQuiescence()) } return Pair(nextState, actions) } @@ -768,4 +885,8 @@ data class Normal( } } } + + private fun endQuiescence(): List { + return commitments.reprocessIncomingHtlcs() + } } 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 d7e0be35f..67165e1a8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt @@ -4,7 +4,6 @@ import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.channel.* import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.transactions.outgoings import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.toByteVector import fr.acinq.lightning.wire.* @@ -428,16 +427,9 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: // When a channel is reestablished after a wallet restarts, we need to reprocess incoming HTLCs that may have been only partially processed // (either because they didn't reach the payment handler, or because the payment handler response didn't reach the channel). // Otherwise these HTLCs will stay in our commitment until they timeout and our peer closes the channel. - // - // We are interested in incoming HTLCs, that have been *cross-signed* (otherwise they wouldn't have been forwarded to the payment handler). - // They signed it first, so the HTLC will first appear in our commitment tx, and later on in their commitment when we subsequently sign it. - // That's why we need to look in *their* commitment with direction=OUT. - // - // We also need to filter out htlcs that we already settled and signed (the settlement messages are being retransmitted). - val alreadySettled = commitments1.changes.localChanges.signed.filterIsInstance().map { it.id }.toSet() - 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) }) + val htlcsToReprocess = commitments1.reprocessIncomingHtlcs() + logger.debug { "re-processing signed IN: ${htlcsToReprocess.map { it.add.id }.joinToString()}" } + sendQueue.addAll(htlcsToReprocess) return Pair(commitments1, sendQueue) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index c5a8ad8de..ff5647753 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -732,6 +732,11 @@ class Peer( } is ChannelAction.EmitEvent -> nodeParams._nodeEvents.emit(action.event) + + is ChannelAction.Disconnect -> { + logger.warning { "channel disconnected due to a protocol error" } + disconnect() + } } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index bb21ebefd..f274a8a53 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -853,6 +853,29 @@ 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) == 1 + ) + } + } +} + 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 6d50ceca5..3bfdfe57c 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) { 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..745234a07 --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -0,0 +1,590 @@ +package fr.acinq.lightning.channel.states + +import fr.acinq.bitcoin.* +import fr.acinq.lightning.CltvExpiry +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Lightning +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.htlcTimeoutTxs +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.tests.utils.runSuspendTest +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.* +import kotlinx.coroutines.CompletableDeferred +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) = reachNormal() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs>(alice2) + assertNull(actionsAlice2.findOutgoingMessageOpt()) + val (_, _, stfu) = crossSignForStfu(alice2, bob1) + assertTrue(stfu.initiator) + } + + @Test + fun `recv stfu when there are pending local changes`() { + val (alice, bob) = reachNormal() + val (alice1, actionsAlice1) = alice.process(createSpliceCommand(alice)) + val stfuAlice = actionsAlice1.findOutgoingMessage() + assertTrue(stfuAlice.initiator) + // we're holding the stfu from alice so that bob can add a pending local change + val (nodes2, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, 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(stfuAlice)) + assertTrue(actionsBob3.isEmpty()) + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Commitment.Sign) + val commitSigBob = actionsBob4.findOutgoingMessage() + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(commitSigBob)) + val revAlice = actionsAlice3.findOutgoingMessage() + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.Commitment.Sign) + val commitSigAlice = actionsAlice4.findOutgoingMessage() + val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(revAlice)) + assertNull(actionsBob5.findOutgoingMessageOpt()) + val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(commitSigAlice)) + val revBob = actionsBob6.findOutgoingMessage() + val stfuBob = actionsBob6.findOutgoingMessage() + assertFalse(stfuBob.initiator) + val (alice5, _) = alice4.process(ChannelCommand.MessageReceived(revBob)) + val (_, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(stfuBob)) + // when both nodes are quiescent, alice can start the splice + val spliceInit = actionsAlice6.findOutgoingMessage() + val (_, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(spliceInit)) + actionsBob7.findOutgoingMessage() + } + + @Test + fun `recv forbidden non-settlement commands while initiator is awaiting stfu from remote`() { + val (alice, _) = reachNormal() + val (alice1, actionsAlice1) = alice.process(createSpliceCommand(alice)) + actionsAlice1.findOutgoingMessage() + // Alice 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), + ) + cmds.forEach { + alice1.process(it).second.findCommandError() + } + } + + @Test + fun `recv forbidden non-settlement commands while quiescent`() { + val (alice, bob) = reachNormal() + val (alice1, bob1, _) = exchangeStfu(createSpliceCommand(alice), alice, bob) + // 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) + ) + cmds.forEach { + alice1.process(it).second.findCommandError() + } + cmds.forEach { + bob1.process(it).second.findCommandError() + } + } + + @Test + fun `recv settlement command while initiator is awaiting stfu from remote`() { + val (alice, bob) = reachNormal() + val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs>(alice2) + val stfuAlice = actionsAlice2.findOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(bob2) + assertTrue(actionsBob2.isEmpty()) + val (_, alice3, stfuBob) = crossSignForStfu(bob2, alice2) + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlc.id, preimage), + ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Alice simply ignores the settlement command. + val (alice4, actionsAlice4) = alice3.process(cmd) + assertTrue(actionsAlice4.isEmpty()) + // But she replays the HTLC once splicing is complete. + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(stfuBob)) + actionsAlice5.findOutgoingMessage() + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, null))) + assertIs(alice6.state) + assertEquals(2, actionsAlice6.size) + assertEquals(htlc, actionsAlice6.find().add) + actionsAlice6.findOutgoingMessage() + // She can now process the command. + val (alice7, actionsAlice7) = alice6.process(cmd) + assertIs(alice7.state) + assertEquals(htlc.id, actionsAlice7.findOutgoingMessage().id) + } + } + + @Test + fun `recv settlement commands while initiator is awaiting stfu from remote and channel disconnects`() { + val (alice, bob) = reachNormal() + val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs>(alice2) + val stfuAlice = actionsAlice2.findOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(bob2) + assertTrue(actionsBob2.isEmpty()) + val (bob3, alice3, _) = crossSignForStfu(bob2, alice2) + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlc.id, preimage), + ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Alice simply ignores the settlement command. + val (alice4, actionsAlice4) = alice3.process(cmd) + assertTrue(actionsAlice4.isEmpty()) + // Alice and Bob disconnect and reconnect, which aborts the quiescence negotiation. + val (aliceOffline, bobOffline) = disconnect(alice4, bob3) + val (alice5, _, actionsAlice5, _) = reconnect(aliceOffline, bobOffline) + assertIs(alice5.state) + assertEquals(1, actionsAlice5.size) + assertEquals(htlc, actionsAlice5.find().add) + // She can now process the command. + val (alice6, actionsAlice6) = alice5.process(cmd) + assertIs(alice6.state) + assertEquals(htlc.id, actionsAlice6.findOutgoingMessage().id) + } + } + + @Test + fun `recv settlement commands while quiescent`() { + val (alice, bob) = reachNormal() + // Alice initiates quiescence with an outgoing HTLC to Bob. + val (nodes1, preimageBob, htlcBob) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs>(alice2) + assertTrue(actionsAlice2.isEmpty()) + val (alice3, bob3, stfuAlice) = crossSignForStfu(alice2, bob1) + // Bob sends an outgoing HTLC to Alice before going quiescent. + val (nodes4, preimageAlice, htlcAlice) = TestsHelper.addHtlc(40_000_000.msat, bob3, alice3) + val (bob4, alice4) = nodes4 + val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(bob5) + assertTrue(actionsBob5.isEmpty()) + val (bob6, alice6, stfuBob) = crossSignForStfu(bob5, alice4) + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.MessageReceived(stfuBob)) + val spliceInit = actionsAlice7.findOutgoingMessage() + val (bob7, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(spliceInit)) + actionsBob7.findOutgoingMessage() + // Alice receives settlement commands. + run { + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlcAlice.id, preimageAlice), + ChannelCommand.Htlc.Settlement.Fail(htlcAlice.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Alice simply ignores the settlement command. + val (alice8, actionsAlice8) = alice7.process(cmd) + assertTrue(actionsAlice8.isEmpty()) + // But she replays the HTLC once splicing is complete. + val (alice9, actionsAlice9) = alice8.process(ChannelCommand.MessageReceived(TxAbort(alice.channelId, null))) + assertIs(alice9.state) + assertEquals(htlcAlice, actionsAlice9.find().add) + // She can now process the command. + val (alice10, actionsAlice10) = alice9.process(cmd) + assertIs(alice10.state) + assertEquals(htlcAlice.id, actionsAlice10.findOutgoingMessage().id) + } + } + // Bob receives settlement commands. + run { + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlcBob.id, preimageBob), + ChannelCommand.Htlc.Settlement.Fail(htlcBob.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Bob simply ignores the settlement command. + val (bob8, actionsBob8) = bob7.process(cmd) + assertTrue(actionsBob8.isEmpty()) + // But he replays the HTLC once splicing is complete. + val (bob9, actionsBob9) = bob8.process(ChannelCommand.MessageReceived(TxAbort(bob.channelId, null))) + assertIs(bob9.state) + assertEquals(htlcBob, actionsBob9.find().add) + // He can now process the command. + val (bob10, actionsBob10) = bob9.process(cmd) + assertIs(bob10.state) + assertEquals(htlcBob.id, actionsBob10.findOutgoingMessage().id) + } + } + } + + @Test + fun `recv settlement commands while quiescent and channel disconnects`() { + val (alice, bob) = reachNormal() + // Alice initiates quiescence with an outgoing HTLC to Bob. + val (nodes1, preimageBob, htlcBob) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs>(alice2) + assertTrue(actionsAlice2.isEmpty()) + val (alice3, bob3, stfuAlice) = crossSignForStfu(alice2, bob1) + // Bob sends an outgoing HTLC to Alice before going quiescent. + val (nodes4, preimageAlice, htlcAlice) = TestsHelper.addHtlc(40_000_000.msat, bob3, alice3) + val (bob4, alice4) = nodes4 + val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(stfuAlice)) + assertIs>(bob5) + assertTrue(actionsBob5.isEmpty()) + val (bob6, alice6, stfuBob) = crossSignForStfu(bob5, alice4) + val (alice7, actionsAlice7) = alice6.process(ChannelCommand.MessageReceived(stfuBob)) + val spliceInit = actionsAlice7.findOutgoingMessage() + val (bob7, actionsBob7) = bob6.process(ChannelCommand.MessageReceived(spliceInit)) + actionsBob7.findOutgoingMessage() + // Alice receives settlement commands. + run { + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlcAlice.id, preimageAlice), + ChannelCommand.Htlc.Settlement.Fail(htlcAlice.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Alice simply ignores the settlement command. + val (alice8, actionsAlice8) = alice7.process(cmd) + assertTrue(actionsAlice8.isEmpty()) + // Alice and Bob disconnect and reconnect, which aborts the quiescence negotiation. + val (aliceOffline, bobOffline) = disconnect(alice8, bob7) + val (alice9, _, actionsAlice9, _) = reconnect(aliceOffline, bobOffline) + assertIs(alice9.state) + assertEquals(1, actionsAlice9.size) + assertEquals(htlcAlice, actionsAlice9.find().add) + // She can now process the command. + val (alice10, actionsAlice10) = alice9.process(cmd) + assertIs(alice10.state) + assertEquals(htlcAlice.id, actionsAlice10.findOutgoingMessage().id) + } + } + // Bob receives settlement commands. + run { + listOf( + ChannelCommand.Htlc.Settlement.Fulfill(htlcBob.id, preimageBob), + ChannelCommand.Htlc.Settlement.Fail(htlcBob.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) + ).forEach { cmd -> + // Bob simply ignores the settlement command. + val (bob8, actionsBob8) = bob7.process(cmd) + assertTrue(actionsBob8.isEmpty()) + // Alice and Bob disconnect and reconnect, which aborts the quiescence negotiation. + val (aliceOffline, bobOffline) = disconnect(alice7, bob8) + val (_, bob9, _, actionsBob9) = reconnect(aliceOffline, bobOffline) + assertIs(bob9.state) + assertEquals(htlcBob, actionsBob9.find().add) + // He can now process the command. + val (bob10, actionsBob10) = bob9.process(cmd) + assertIs(bob10.state) + assertEquals(htlcBob.id, actionsBob10.findOutgoingMessage().id) + } + } + } + + @Test + fun `recv second stfu while non-initiator is waiting for local commitment to be signed`() { + val (alice, bob) = reachNormal() + val (alice1, actionsAlice1) = alice.process(createSpliceCommand(alice)) + val stfu = actionsAlice1.findOutgoingMessage() + val (nodes2, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, alice1) + val (bob2, _) = nodes2 + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(stfu)) + assertTrue(actionsBob3.isEmpty()) + // second stfu to bob is ignored + val (_, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(stfu)) + assertTrue(actionsBob4.isEmpty()) + } + + @Test + fun `recv Shutdown message before initiator receives stfu from remote`() { + val (alice, bob) = reachNormal() + // Alice initiates quiescence. + val (alice1, actionsAlice1) = alice.process(createSpliceCommand(alice)) + val stfuAlice = actionsAlice1.findOutgoingMessage() + // But Bob is concurrently initiating a mutual close, which should "win". + val (bob1, actionsBob1) = bob.process(ChannelCommand.Close.MutualClose(null, null)) + val shutdownBob = actionsBob1.hasOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) + assertNull(actionsBob2.findOutgoingMessageOpt()) + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) + assertIs(alice2.state) + val shutdownAlice = actionsAlice2.findOutgoingMessage() + actionsAlice2.findOutgoingMessage() + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(shutdownAlice)) + assertIs(bob3.state) + actionsBob3.has() + } + + @Test + fun `recv forbidden settlement messages while quiescent`() { + val (alice, bob) = reachNormal() + val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2) = TestsHelper.crossSign(bob1, alice1) + val (alice3, bob3, _) = exchangeStfu(createSpliceCommand(alice2), alice2, bob2) + listOf( + UpdateFulfillHtlc(bob3.channelId, htlc.id, preimage), + UpdateFailHtlc(bob3.channelId, htlc.id, Lightning.randomBytes32()), + UpdateFee(bob3.channelId, FeeratePerKw(500.sat)), + UpdateAddHtlc(Lightning.randomBytes32(), htlc.id + 1, 50000000.msat, Lightning.randomBytes32(), CltvExpiry(alice.currentBlockHeight.toLong()), TestConstants.emptyOnionPacket), + Shutdown(alice.channelId, alice.commitments.params.localParams.defaultFinalScriptPubKey), + ).forEach { + // both parties will respond to a forbidden msg while quiescent with a warning (and disconnect) + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(it)) + assertEquals(alice3, alice4) + actionsAlice4.findOutgoingMessage() + actionsAlice4.has() + val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(it)) + assertEquals(bob3, bob4) + actionsBob4.findOutgoingMessage() + actionsBob4.has() + } + } + + @Test + fun `recv stfu from splice initiator that is not quiescent`() { + val (alice, bob) = reachNormal() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (nodes2, _, _) = TestsHelper.addHtlc(40_000_000.msat, bob1, alice1) + val (bob2, alice2) = nodes2 + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(Stfu(alice.channelId, initiator = true))) + assertEquals(bob2, bob3) + actionsBob3.findOutgoingMessage() + actionsBob3.find() + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(Stfu(alice.channelId, initiator = true))) + assertEquals(alice2, alice3) + actionsAlice3.findOutgoingMessage() + actionsAlice3.find() + } + + @Test + fun `recv stfu from splice non-initiator that is not quiescent`() { + val (alice, bob) = reachNormal() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (_, alice1) = nodes1 + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) + assertIs(alice2.state) + actionsAlice2.findOutgoingMessage() + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(Stfu(bob.channelId, initiator = false))) + assertIs(alice3.state) + assertEquals(alice2.state.copy(spliceStatus = SpliceStatus.None), alice3.state) + actionsAlice3.findOutgoingMessage() + actionsAlice3.find() + } + + @Test + fun `initiate quiescence concurrently with no pending changes`() = runSuspendTest { + val (alice, bob) = reachNormal() + val cmdAlice = createSpliceCommand(alice) + val cmdBob = createSpliceCommand(bob) + val (alice1, actionsAlice1) = alice.process(cmdAlice) + val stfuAlice = actionsAlice1.findOutgoingMessage() + assertTrue(stfuAlice.initiator) + val (bob1, actionsBob1) = bob.process(cmdBob) + val stfuBob = actionsBob1.findOutgoingMessage() + assertTrue(stfuBob.initiator) + // Alice is the channel initiator, so she has precedence and remains the splice initiator. + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(stfuBob)) + val spliceInit = actionsAlice2.findOutgoingMessage() + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) + assertTrue(actionsBob2.isEmpty()) + val (_, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsBob3.findOutgoingMessage() + val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(spliceAck)) + actionsAlice3.hasOutgoingMessage() + withTimeout(100) { + assertIs(cmdBob.replyTo.await()) + } + } + + @Test + fun `initiate quiescence concurrently with pending changes on one side`() = runSuspendTest { + val (alice, bob) = reachNormal() + val (nodes1, _, _) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val cmdAlice = createSpliceCommand(alice1) + val cmdBob = createSpliceCommand(bob1) + val (alice2, actionsAlice2) = alice1.process(cmdAlice) + assertTrue(actionsAlice2.isEmpty()) // alice isn't quiescent yet + val (bob2, actionsBob2) = bob1.process(cmdBob) + val stfuBob = actionsBob2.findOutgoingMessage() + assertTrue(stfuBob.initiator) + val (alice3, _) = alice2.process(ChannelCommand.MessageReceived(stfuBob)) + assertIs>(alice3) + assertIs>(bob2) + val (alice4, bob3, stfuAlice) = crossSignForStfu(alice3, bob2) + assertFalse(stfuAlice.initiator) + val (bob4, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(stfuAlice)) + val spliceInit = actionsBob4.findOutgoingMessage() + val (_, actionsAlice5) = alice4.process(ChannelCommand.MessageReceived(spliceInit)) + val spliceAck = actionsAlice5.findOutgoingMessage() + val (_, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(spliceAck)) + actionsBob5.hasOutgoingMessage() + withTimeout(100) { + assertIs(cmdAlice.replyTo.await()) + } + } + + @Test + fun `outgoing htlc timeout during quiescence negotiation`() { + val (alice, bob) = reachNormal() + val (nodes1, _, add) = TestsHelper.addHtlc(50_000_000.msat, alice, bob) + val (alice1, bob1) = nodes1 + val (alice2, bob2) = TestsHelper.crossSign(alice1, bob1) + val (alice3, _, _) = exchangeStfu(createSpliceCommand(alice2), alice2, bob2) + // The outgoing HTLC from Alice has timed out: she should force-close to avoid an on-chain race. + val (alice4, actionsAlice4) = run { + val tmp = alice3.copy(ctx = alice3.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt())) + tmp.process(ChannelCommand.Commitment.CheckHtlcTimeout) + } + assertIs(alice4.state) + val lcp = alice4.state.localCommitPublished + assertNotNull(lcp) + assertEquals(1, lcp.htlcTxs.size) + val htlcTimeoutTxs = lcp.htlcTimeoutTxs() + assertEquals(1, htlcTimeoutTxs.size) + actionsAlice4.hasPublishTx(lcp.commitTx) + actionsAlice4.hasPublishTx(lcp.htlcTimeoutTxs().first().tx) + } + + @Test + fun `incoming htlc timeout during quiescence negotiation`() { + val (alice, bob) = reachNormal() + val (nodes1, preimage, add) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2) = TestsHelper.crossSign(bob1, alice1) + val (alice3, _, _) = exchangeStfu(createSpliceCommand(alice2), alice2, bob2) + listOf( + ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)), + ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage) + ).forEach { cmd -> + // Alice simply ignores the settlement command during quiescence. + val (alice4, actionsAlice4) = alice3.process(cmd) + assertTrue(actionsAlice4.isEmpty()) + // The incoming HTLC to Alice has timed out: it is Bob's responsibility to force-close. + // If Bob doesn't force-close, Alice will fulfill or fail the HTLC when they reconnect. + val (alice5, actionsAlice5) = run { + val tmp = alice4.copy(ctx = alice4.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt())) + tmp.process(ChannelCommand.Commitment.CheckHtlcTimeout) + } + assertTrue(actionsAlice5.isEmpty()) + // Alice replays the HTLC once splicing is complete. + val (alice6, actionsAlice6) = alice5.process(ChannelCommand.MessageReceived(TxAbort(alice5.channelId, "deadbeef"))) + assertIs(alice6.state) + assertEquals(add, actionsAlice6.find().add) + // She can now process the command. + val (alice7, actionsAlice7) = alice6.process(cmd) + assertIs(alice7.state) + assertEquals(add.id, actionsAlice7.findOutgoingMessage().id) + } + } + + @Test + fun `receive SpliceInit when channel is not quiescent`() { + val (alice, bob) = reachNormal() + val (_, _, spliceInit) = exchangeStfu(createSpliceCommand(alice), alice, bob) + // If we send splice_init to Bob's before reaching quiescence, he simply rejects it. + val (bob2, actionsBob2) = bob.process(ChannelCommand.MessageReceived(spliceInit)) + assertEquals(bob.state.copy(spliceStatus = SpliceStatus.Aborted), bob2.state) + actionsBob2.hasOutgoingMessage() + } + + companion object { + private fun createWalletWithFunds(keyManager: KeyManager, utxos: List): List { + val script = keyManager.swapInOnChainWallet.pubkeyScript + return utxos.map { amount -> + val txIn = listOf(TxIn(OutPoint(TxId(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) + } + } + + fun createSpliceCommand(sender: LNChannel, spliceIn: List = listOf(500_000.sat), spliceOut: Satoshi? = 100_000.sat): ChannelCommand.Commitment.Splice.Request { + return ChannelCommand.Commitment.Splice.Request( + replyTo = CompletableDeferred(), + spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(sender.staticParams.nodeParams.keyManager, spliceIn)), + spliceOut = spliceOut?.let { ChannelCommand.Commitment.Splice.Request.SpliceOut(it, Script.write(Script.pay2wpkh(Lightning.randomKey().publicKey())).byteVector()) }, + feerate = FeeratePerKw(253.sat) + ) + } + + /** Use this function when both nodes are already quiescent and want to exchange stfu. */ + fun exchangeStfu(cmd: ChannelCommand.Commitment.Splice.Request, sender: LNChannel, receiver: LNChannel): Triple, LNChannel, SpliceInit> { + val (sender1, sActions1) = sender.process(cmd) + val stfu1 = sActions1.findOutgoingMessage() + assertTrue(stfu1.initiator) + val (receiver1, rActions1) = receiver.process(ChannelCommand.MessageReceived(stfu1)) + val stfu2 = rActions1.findOutgoingMessage() + assertFalse(stfu2.initiator) + val (sender2, sActions2) = sender1.process(ChannelCommand.MessageReceived(stfu2)) + val spliceInit = sActions2.findOutgoingMessage() + assertIs>(sender2) + assertIs>(receiver1) + return Triple(sender2, receiver1, spliceInit) + } + + /** Use this function when the sender has pending changes that need to be cross-signed before sending stfu. */ + fun crossSignForStfu(sender: LNChannel, receiver: LNChannel): Triple, LNChannel, Stfu> { + val (sender2, sActions2) = sender.process(ChannelCommand.Commitment.Sign) + val sCommitSig = sActions2.findOutgoingMessage() + val (receiver2, rActions2) = receiver.process(ChannelCommand.MessageReceived(sCommitSig)) + val rRev = rActions2.findOutgoingMessage() + val (receiver3, rActions3) = receiver2.process(ChannelCommand.Commitment.Sign) + val rCommitSig = rActions3.findOutgoingMessage() + val (sender3, sActions3) = sender2.process(ChannelCommand.MessageReceived(rRev)) + assertNull(sActions3.findOutgoingMessageOpt()) + val (sender4, sActions4) = sender3.process(ChannelCommand.MessageReceived(rCommitSig)) + val sRev = sActions4.findOutgoingMessage() + val stfu = sActions4.findOutgoingMessage() + val (receiver4, _) = receiver3.process(ChannelCommand.MessageReceived(sRev)) + assertIs>(sender4) + assertIs>(receiver4) + return Triple(sender4, receiver4, stfu) + } + + fun disconnect(alice: LNChannel, bob: LNChannel): Pair, LNChannel> { + 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()) + assertIs>(alice1) + assertIs>(bob1) + return Pair(alice1, bob1) + } + + data class PostReconnectionState(val alice: LNChannel, val bob: LNChannel, val actionsAlice: List, val actionsBob: List) + + fun reconnect(alice: LNChannel, bob: LNChannel): PostReconnectionState { + val aliceInit = Init(alice.commitments.params.localParams.features) + val bobInit = Init(bob.commitments.params.localParams.features) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Connected(aliceInit, bobInit)) + assertIs>(alice1) + val channelReestablishA = actionsAlice1.findOutgoingMessage() + val (bob1, _) = bob.process(ChannelCommand.Connected(bobInit, aliceInit)) + assertIs>(bob1) + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(channelReestablishA)) + val channelReestablishB = actionsBob2.findOutgoingMessage() + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(channelReestablishB)) + assertIs>(alice2) + assertIs>(bob2) + return PostReconnectionState(alice2, bob2, actionsAlice2, actionsBob2) + } + } + +} 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 0cda32304..dc4fb9930 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -110,9 +110,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) @@ -124,8 +123,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) @@ -138,8 +137,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())) @@ -164,8 +163,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())) @@ -198,8 +197,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())) @@ -999,11 +998,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)) @@ -1034,11 +1032,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)) @@ -1073,11 +1070,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)) @@ -1324,6 +1320,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) + } } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt index de788e393..e696c284d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt @@ -331,6 +331,22 @@ class SyncingTestsCommon : LightningTestSuite() { actions1.hasOutgoingMessage() } + @Test + fun `recv Disconnect after adding htlc but before processing settlement`() { + val (alice, bob) = init() + val (nodes1, _, add) = TestsHelper.addHtlc(55_000_000.msat, payer = bob, payee = alice) + val (bob1, alice1) = nodes1 + val (bob2, alice2) = TestsHelper.crossSign(bob1, alice1) + + // Disconnect before Alice's payment handler processes the htlc. + val (alice3, _, reestablish) = disconnect(alice2, bob2) + + // After reconnecting, Alice forwards the htlc again to her payment handler. + val (_, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(reestablish.second)) + val processIncomingHtlc = actionsAlice4.find() + assertEquals(processIncomingHtlc.add, add) + } + companion object { fun init(): Pair, LNChannel> { // NB: we disable channel backups to ensure Bob sends his channel_reestablish on reconnection. diff --git a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/ConnectionTest.kt b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/ConnectionTest.kt index ca2e86f5a..ac3ce5c52 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/io/peer/ConnectionTest.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/io/peer/ConnectionTest.kt @@ -1,7 +1,7 @@ package fr.acinq.lightning.io.peer -import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.channel.TestsHelper.reachNormal +import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.io.Disconnected import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.io.peer.newPeer