diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index b2f960a8a..4a4dfc912 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -83,6 +83,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 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 5fcbe1514..7f2575ead 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -576,11 +576,16 @@ data class Commitments( // @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), we need to reprocess those incoming HTLCs. + * 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 { + 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) } } 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 03efeb9ba..6f0c35828 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -29,20 +29,9 @@ data class Normal( override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) override fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { - 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. - logger.warning { "ignoring ${cmd::class.simpleName} for htlc #${cmd.id} during quiescence: will be replayed once quiescence ends" } - Pair(this@Normal, listOf()) - } - else -> handleCommandError(cmd, error, channelUpdate) - } - } - if (cmd is ChannelCommand.ForbiddenDuringSplice && spliceStatus is QuiescentSpliceStatus) { - val error = ForbiddenDuringSplice(channelId, cmd::class.simpleName) + 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. @@ -50,7 +39,7 @@ data class Normal( 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, error, channelUpdate) + else -> handleCommandError(cmd, ForbiddenDuringSplice(channelId, cmd::class.simpleName), channelUpdate) } } return when (cmd) { @@ -132,16 +121,14 @@ data class Normal( } is ChannelCommand.MessageReceived -> when { 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) - val warn = ChannelAction.Message.Send(Warning(channelId, error.message)) + 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(warn) + add(ChannelAction.Message.Send(Warning(channelId, ForbiddenDuringSplice(channelId, cmd.message::class.simpleName).message))) add(ChannelAction.Disconnect) } Pair(this@Normal, actions) @@ -357,9 +344,21 @@ data class Normal( } } } - - is Stfu -> if (commitments.remoteIsQuiescent()) { - when (spliceStatus) { + 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)))) @@ -386,9 +385,10 @@ data class Normal( localOutputs = spliceStatus.command.spliceOutputs, targetFeerate = spliceStatus.command.feerate ) - val commitTxFees = if (commitments.params.localParams.isInitiator) { - Transactions.commitTxFee(commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec) - } else 0.sat + 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) @@ -431,20 +431,13 @@ data class Normal( spliceStatus.command.replyTo.complete(ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice) Pair(this@Normal.copy(spliceStatus = SpliceStatus.NonInitiatorQuiescent), emptyList()) } - } else -> { + } + 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) - } - Pair(this@Normal.copy(spliceStatus = SpliceStatus.None), actions) } - is SpliceInit -> when (spliceStatus) { is SpliceStatus.None -> { logger.warning { "rejecting splice attempt: quiescence not negotiated" } 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..20fbc9340 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/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index f2e01f6d4..f26fcf3b7 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -856,13 +856,14 @@ data class ChannelReady( data class Stfu( override val channelId: ByteVector32, val initiator: Boolean -) : SetupMessage, HasChannelId { +) : 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 diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index 182e4cff9..dcdf8a370 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -474,7 +474,7 @@ object TestsHelper { /** * Cross sign nodes where nodeA initiate the signature exchange */ - fun crossSign(nodeA: LNChannel, nodeB: LNChannel, commitmentsCount: Int = 1): Triple, LNChannel, List> { + fun crossSign(nodeA: LNChannel, nodeB: LNChannel, commitmentsCount: Int = 1): Pair, LNChannel> { val sCommitIndex = nodeA.state.commitments.localCommitIndex val rCommitIndex = nodeB.state.commitments.localCommitIndex val rHasChanges = nodeB.state.commitments.changes.localHasChanges() @@ -497,7 +497,7 @@ object TestsHelper { val (sender2, sActions2) = receiveCommitSigs(sender1, commitSigs1) val revokeAndAck1 = sActions2.findOutgoingMessage() - val (receiver2, rActions2) = receiver1.process(ChannelCommand.MessageReceived(revokeAndAck1)) + val (receiver2, _) = receiver1.process(ChannelCommand.MessageReceived(revokeAndAck1)) assertIs>(receiver2) if (rHasChanges) { @@ -508,7 +508,7 @@ object TestsHelper { val (receiver3, rActions3) = receiveCommitSigs(receiver2, commitSigs2) val revokeAndAck2 = rActions3.findOutgoingMessage() - val (sender4, sActions4) = sender3.process(ChannelCommand.MessageReceived(revokeAndAck2)) + val (sender4, _) = sender3.process(ChannelCommand.MessageReceived(revokeAndAck2)) assertIs>(sender4) assertIs>(receiver3) @@ -517,7 +517,7 @@ object TestsHelper { assertEquals(rCommitIndex + 2, receiver3.commitments.localCommitIndex) assertEquals(sCommitIndex + 1, receiver3.commitments.remoteCommitIndex) - return Triple(sender4, receiver3, sActions4 + rActions3) + return sender4 to receiver3 } else { assertIs>(sender2) assertEquals(sCommitIndex + 1, sender2.commitments.localCommitIndex) @@ -525,7 +525,7 @@ object TestsHelper { assertEquals(rCommitIndex + 1, receiver2.commitments.localCommitIndex) assertEquals(sCommitIndex + 1, receiver2.commitments.remoteCommitIndex) - return Triple(sender2, receiver2, sActions2 + rActions2) + return sender2 to receiver2 } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt index 170ec1031..dd0aa0ce3 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -1,25 +1,23 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* +import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.CltvExpiryDelta -import fr.acinq.lightning.Feature 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.crossSign -import fr.acinq.lightning.channel.TestsHelper.htlcSuccessTxs 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.runBlocking import kotlinx.coroutines.withTimeout import kotlin.test.* @@ -28,71 +26,74 @@ 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 (alice, bob) = reachNormal() 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) + val (alice2, actionsAlice2) = alice1.process(createSpliceCommand(alice1)) assertIs>(alice2) - val (_, _, actionsAlice2) = crossSign(alice2, bob1) - actionsAlice2.findOutgoingMessage() + assertNull(actionsAlice2.findOutgoingMessageOpt()) + val (_, _, stfu) = crossSignForStfu(alice2, bob1) + assertTrue(stfu.initiator) } @Test - fun `recv stfu when there are pending local changes` () { - val (alice, bob) = init() - val (alice1, bob1, stfu) = initiateQuiescence(alice, bob, sendInitialStfu = false) + 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, bob1, alice1) + 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(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)) + 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 awaiting stfu from remote` () { - val (alice, bob) = init() - // initiator should reject commands that change the commitment once it became quiescent + 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), ) - val (alice1, _, _) = initiateQuiescence(alice, bob, sendInitialStfu = false) cmds.forEach { - alice1.process(it).second.findCommandError() + alice1.process(it).second.findCommandError() } } @Test - fun `recv forbidden non-settlement commands while quiescent` () { - val (alice, bob) = init() + 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) ) - val (alice1, bob1, _) = initiateQuiescence(alice, bob, sendInitialStfu = true) cmds.forEach { alice1.process(it).second.findCommandError() } @@ -102,319 +103,457 @@ class QuiescenceTestsCommon : LightningTestSuite() { } @Test - fun `recv settlement command while initiator awaiting stfu from remote` () { - val (alice, bob) = init() - receiveSettlementCommand(alice, bob, sendInitialStfu = false) + 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 awaiting stfu from remote and channel disconnects` () { - val (alice, bob) = init() - receiveSettlementCommand(alice, bob,sendInitialStfu = false, resetConnection = true) + 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) = init() - receiveSettlementCommand(alice, bob, sendInitialStfu = true) + 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) = init() - receiveSettlementCommand(alice, bob, sendInitialStfu = true, resetConnection = true) + 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 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)) + 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 (_, 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() + val (_, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(stfu)) + assertTrue(actionsBob4.isEmpty()) } @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() + 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) = init() + 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, _) = crossSign(bob1, alice1) - val (alice3, bob3, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) + 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(), id = 5656, amountMsat = 50000000.msat, cltvExpiry = CltvExpiryDelta(144).toCltvExpiry(alice.currentBlockHeight.toLong()), paymentHash = Lightning.randomBytes32(), onionRoutingPacket = TestConstants.emptyOnionPacket) + 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 (_, actionsAlice3) = alice3.process(ChannelCommand.MessageReceived(it)) - actionsAlice3.findOutgoingMessage() - actionsAlice3.has() - val (_, actionsBob3) = bob3.process(ChannelCommand.MessageReceived(it)) - actionsBob3.findOutgoingMessage() - actionsBob3.has() + 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 `fulfilled htlc times out while offline after quiescence` () { - 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, bob3, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) - val forbiddenCmd = ChannelCommand.Htlc.Settlement.Fulfill(add.id, preimage) - val (alice4, actionsAlice4) = alice3.process(forbiddenCmd) - assertEquals(actionsAlice4.size, 0) - - val (alice5, bob4) = disconnect(alice4, bob3) - - // the incoming HTLC from bob has timed out, alice should fail the htlc to avoid an on-chain race - val (alice6, actionsAlice6) = run { - val tmp = alice5.copy(ctx = alice5.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt())) - tmp.process(ChannelCommand.Commitment.CheckHtlcTimeout) - } - - // while offline, stashed incoming HTLC settlements are forgotten so alice will not close the channel with bob - assertEquals(actionsAlice6.size, 0) - - assertIs>(alice6) - val (_, _, actions) = reconnect(alice6, bob4) - actions.first.has() - // when alice processes the incoming htlc, she will fail it because the current height is now too close to its expiry - } - - @Test - fun `recv stfu from splice initiator that is not quiescent` () { - val (alice, bob) = init() + 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 (_, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(Stfu(alice1.channelId, initiator = true))) - // non-initiator will send a warning (and disconnect) - actionsBob2.findOutgoingMessage() - actionsBob2.find() + 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) = init() + 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 (bob1, alice1) = nodes1 - val (alice2, bob2, _) = initiateQuiescence(alice1, bob1, sendInitialStfu = false) - val (_, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(Stfu(bob2.channelId, initiator = false))) - // initiator will send a warning (and disconnect) + 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` () { - val (alice, bob) = init() - val cmdAlice = 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 cmdBob = ChannelCommand.Commitment.Splice.Request( - replyTo = CompletableDeferred(), - spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(bob.staticParams.nodeParams.keyManager, listOf(500_000.sat))), - spliceOut = null, - feerate = FeeratePerKw(253.sat) - ) + 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() - val (_, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(stfuBob)) - val (bob2, _) = bob1.process(ChannelCommand.MessageReceived(stfuAlice)) - // alice remains the initiator, bob becomes the non-initiator and fails the command - actionsAlice2.findOutgoingMessage() - assertIs>(bob2) - assertIs(bob2.state.spliceStatus) - runBlocking { - withTimeout(100) { - assertIs(cmdBob.replyTo.await()) - } + 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 initiator side` () { - val (alice, bob) = init() + 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 = 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 cmdBob = ChannelCommand.Commitment.Splice.Request( - replyTo = CompletableDeferred(), - spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(bob.staticParams.nodeParams.keyManager, listOf(500_000.sat))), - spliceOut = null, - feerate = FeeratePerKw(253.sat) - ) + val cmdAlice = createSpliceCommand(alice1) + val cmdBob = createSpliceCommand(bob1) val (alice2, actionsAlice2) = alice1.process(cmdAlice) - assertEquals(actionsAlice2.size, 0) // alice isn't quiescent yet + 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, aliceActions4) = crossSign(alice3, bob2) - val stfuAlice = aliceActions4.findOutgoingMessage() - val (_, actionsBob4) = bob3.process(ChannelCommand.MessageReceived(stfuAlice)) - // alice receives stfu before sending their own stfu so bob becomes the initiator and alice fails the command - assertIs>(alice4) - assertIs(alice4.state.spliceStatus) - runBlocking { - withTimeout(100) { - assertIs(cmdAlice.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 aliceCmd = 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 cmdBob = ChannelCommand.Commitment.Splice.Request( - replyTo = CompletableDeferred(), - spliceIn = ChannelCommand.Commitment.Splice.Request.SpliceIn(createWalletWithFunds(bob.staticParams.nodeParams.keyManager, listOf(500_000.sat))), - spliceOut = null, - feerate = FeeratePerKw(253.sat) - ) - val (alice2, actionsAlice2) = alice1.process(aliceCmd) - val stfuAlice = actionsAlice2.findOutgoingMessage() - val (bob2, actionsBob2) = bob1.process(cmdBob) - 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() - // alice remains the initiator, bob becomes the non-initiator and fails the command - val (_, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(stfuBob)) - assertIs>(bob4) - assertIs(bob4.state.spliceStatus) - runBlocking { - withTimeout(100) { - assertIs(cmdBob.replyTo.await()) - } + 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()) } - actionsAlice4.findOutgoingMessage() } @Test - fun `outgoing htlc timeout during quiescence negotiation` () { - val (alice, bob) = init() + 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, _) = crossSign(alice1, bob1) - val (alice3, _, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) - assertIs>(alice3) - - // the outgoing HTLC from alice has timed out, alice should fail the htlc to avoid an on-chain race - val (alice4, _) = run { + 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) } - - // when the HTLC times out, alice needs to force close the channel - assertIs>(alice4) + assertIs(alice4.state) val lcp = alice4.state.localCommitPublished assertNotNull(lcp) assertEquals(1, lcp.htlcTxs.size) val htlcTimeoutTxs = lcp.htlcTimeoutTxs() assertEquals(1, htlcTimeoutTxs.size) - val htlcSuccessTxs = lcp.htlcSuccessTxs() - assertEquals(0, htlcSuccessTxs.size) - - // Valid txs should be detected: - htlcTimeoutTxs.forEach { tx -> assertTrue(lcp.isHtlcTimeout(tx.tx)) } + actionsAlice4.hasPublishTx(lcp.commitTx) + actionsAlice4.hasPublishTx(lcp.htlcTimeoutTxs().first().tx) } @Test - fun `incoming htlc timeout during quiescence negotiation with pending htlc failure or success` () { - val (alice, bob) = init() + 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, _) = crossSign(bob1, alice1) - val (alice3, _, _) = initiateQuiescence(alice2, bob2, sendInitialStfu = true) - assertIs>(alice3) - // alice receives a fail or success for the htlc, which is ignored because the channel is quiescent + 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 { - val (alice4, actionsAlice4) = alice3.process(it) - assertEquals(actionsAlice4.size, 0) - // the incoming HTLC from bob will soon timeout, alice should fail the htlc to avoid an on-chain race - val (alice5, aliceActions5) = run { - val tmp = alice3.copy(ctx = alice4.ctx.copy(currentBlockHeight = add.cltvExpiry.toLong().toInt() - TestConstants.Alice.nodeParams.fulfillSafetyBeforeTimeoutBlocks.toInt())) + ).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) } - // alice does not close the channel with bob immediately - assertEquals(aliceActions5.size, 0) - // alice will process the incoming htlc settlement after quiescence - val (_, aliceActions6) = alice5.process(ChannelCommand.MessageReceived(TxAbort(alice5.channelId, "deadbeef"))) - assertContains(aliceActions6, ChannelAction.ProcessIncomingHtlc(add)) - // alice should fail the htlc because the current height is now too close to its expiry + 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, _) = init() - val spliceInit = SpliceInit(alice.channelId, 500_000.sat, FeeratePerKw(253.sat), 0, Lightning.randomKey().publicKey()) - val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(spliceInit)) - // quiescence not negotiated - actionsAlice1.hasOutgoingMessage() - assertIs>(alice1) - assertIs(alice1.state.spliceStatus) + 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 { - 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) + private fun createWalletWithFunds(keyManager: KeyManager, utxos: List): List { + val script = keyManager.swapInOnChainWallet.pubkeyScript + return utxos.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) + } + } + + 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> { @@ -429,122 +568,22 @@ class QuiescenceTestsCommon : LightningTestSuite() { return Pair(alice1, bob1) } - fun reconnect(alice: LNChannel, bob: LNChannel): Triple, LNChannel, Pair, List>> { + 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) - assertFalse(bob.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupClient)) - val (alice1, actionsAlice1) = alice.process(ChannelCommand.Connected(aliceInit, bobInit)) assertIs>(alice1) val channelReestablishA = actionsAlice1.findOutgoingMessage() - val (bob1, actionsBob1) = bob.process(ChannelCommand.Connected(bobInit, aliceInit)) + val (bob1, _) = bob.process(ChannelCommand.Connected(bobInit, aliceInit)) assertIs>(bob1) - val channelReestablishB = actionsBob1.findOutgoingMessage() - val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(channelReestablishB)) 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 Triple(alice2, bob2, Pair(actionsAlice2, actionsBob2)) - } - } - - 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) - } - - private fun receiveSettlementCommand(alice: LNChannel, bob: LNChannel, sendInitialStfu: Boolean, resetConnection: Boolean = false) { - val (nodes1, preimage, htlc) = TestsHelper.addHtlc(50_000_000.msat, bob, alice) - val (bob1, alice1) = nodes1 - listOf( - ChannelCommand.Htlc.Settlement.Fulfill(htlc.id, preimage), - ChannelCommand.Htlc.Settlement.Fail(htlc.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(TemporaryNodeFailure)) - ).forEach { - 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(it) - 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.channelId, "deadbeef"))) - val abort = actionsAlice5.findOutgoingMessage() - val (bob5, actionsBob5) = bob4.process(ChannelCommand.MessageReceived(abort)) - actionsBob5.findOutgoingMessage() - // alice reprocesses incoming payments after quiescence ends - assertContains(actionsAlice5, ChannelAction.ProcessIncomingHtlc(htlc)) - Pair(alice5, bob5) - } else { - // alice will eventually disconnect if bob does not send stfu. - disconnect(alice3, bob3) - } - val (alice6, bob6) = units - if (resetConnection || !sendInitialStfu) { - // any failure during quiescence will cause alice to disconnect - assertIs>(alice6) - assertIs>(bob6) - val (alice7, _, actions) = reconnect(alice6, bob6) - - assertNotNull(spliceCommand) - runBlocking { - withTimeout(100) { - assertIs(spliceCommand.replyTo.await()) - } - } - assertTrue(alice7.state.spliceStatus is SpliceStatus.None) - - // after reconnecting, Alice resends ProcessIncomingHtlc to Peer - val processIncomingHtlc = actions.first.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) + return PostReconnectionState(alice2, bob2, actionsAlice2, actionsBob2) } } 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 9a79f0e39..e34371d8f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SyncingTestsCommon.kt @@ -333,20 +333,19 @@ class SyncingTestsCommon : LightningTestSuite() { } @Test - fun `recv Disconnect after adding htlc but before processing settlement` () { + fun `recv Disconnect after adding htlc but before processing settlement`() { val (alice, bob) = init() - val (nodes1, _, _) = TestsHelper.addHtlc(55_000_000.msat, payer = bob, payee = alice) + val (nodes1, _, add) = TestsHelper.addHtlc(55_000_000.msat, payer = bob, payee = alice) val (bob1, alice1) = nodes1 - val (bob2, alice2, aliceActions2) = TestsHelper.crossSign(bob1, alice1) - val processIncomingHtlc = aliceActions2.find() + val (bob2, alice2) = TestsHelper.crossSign(bob1, alice1) - // disconnect before Peer receives ProcessIncomingHtlc from Alice + // Disconnect before Alice's payment handler processes the htlc. val (alice3, _, reestablish) = disconnect(alice2, bob2) - // after reconnecting, Alice resends ProcessIncomingHtlc to Peer + // After reconnecting, Alice forwards the htlc again to her payment handler. val (_, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(reestablish.second)) - val processIncomingHtlc1 = actionsAlice4.find() - assertEquals(processIncomingHtlc, processIncomingHtlc1) + val processIncomingHtlc = actionsAlice4.find() + assertEquals(processIncomingHtlc.add, add) } companion object { 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 9969ee78f..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,17 +1,13 @@ package fr.acinq.lightning.io.peer -import fr.acinq.lightning.Lightning import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.channel.states.Offline import fr.acinq.lightning.io.Disconnected -import fr.acinq.lightning.io.MessageReceived import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.io.peer.newPeer import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.tests.utils.runSuspendTest import fr.acinq.lightning.utils.Connection -import fr.acinq.lightning.wire.Stfu -import fr.acinq.lightning.wire.UpdateFulfillHtlc import kotlinx.coroutines.flow.first import kotlin.test.Test import kotlin.test.assertEquals