From 32bccb381b8e943372bfeaff270001c9b8177d40 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 28 Apr 2021 18:41:57 +0200 Subject: [PATCH] More flexible mutual close fees Add support for https://github.com/lightningnetwork/lightning-rfc#847 With legacy nodes, we will keep the existing behavior (slowly converge on a fee). But for nodes that support closing fee_ranges, mutual close will take much less round-trips. --- .../fr/acinq/lightning/channel/Channel.kt | 235 +++++---- .../lightning/channel/ChannelCommands.kt | 14 +- .../fr/acinq/lightning/channel/Helpers.kt | 28 +- .../serialization/v1/ChannelState.kt | 9 +- .../serialization/v2/ChannelState.kt | 9 +- .../serialization/v3/ChannelState.kt | 34 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 15 + .../acinq/lightning/wire/LightningMessages.kt | 5 +- .../fr/acinq/lightning/channel/TestsHelper.kt | 54 +- .../channel/states/ClosingTestsCommon.kt | 102 ++-- .../channel/states/NegotiatingTestsCommon.kt | 486 ++++++++++++++---- .../channel/states/NormalTestsCommon.kt | 34 +- .../channel/states/ShutdownTestsCommon.kt | 8 +- .../states/WaitForAcceptChannelTestsCommon.kt | 2 +- .../WaitForFundingConfirmedTestsCommon.kt | 2 +- .../WaitForFundingCreatedTestsCommon.kt | 2 +- .../states/WaitForFundingLockedTestsCommon.kt | 2 +- .../states/WaitForFundingSignedTestsCommon.kt | 2 +- .../states/WaitForOpenChannelTestsCommon.kt | 2 +- .../wire/LightningCodecsTestsCommon.kt | 54 +- src/jvmTest/kotlin/fr/acinq/lightning/Node.kt | 2 +- 21 files changed, 748 insertions(+), 353 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt index fcb9211fe..30b7fd999 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt @@ -1007,7 +1007,7 @@ data class Syncing(val state: ChannelStateWithCommitments, val waitForTheirReest state.commitments, state.localShutdown.scriptPubKey.toByteArray(), state.remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate + state.closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) ) val closingTxProposed1 = state.closingTxProposed + listOf(listOf(ClosingTxProposed(closingTx, closingSigned))) val nextState = state.copy(closingTxProposed = closingTxProposed1) @@ -1778,6 +1778,7 @@ data class WaitForFundingLocked( initialChannelUpdate, null, null, + null, null ) val actions = listOf( @@ -1830,7 +1831,8 @@ data class Normal( val channelUpdate: ChannelUpdate, val remoteChannelUpdate: ChannelUpdate?, val localShutdown: Shutdown?, - val remoteShutdown: Shutdown? + val remoteShutdown: Shutdown?, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) @@ -1893,7 +1895,7 @@ data class Normal( !Helpers.Closing.isValidFinalScriptPubkey(localScriptPubkey) -> handleCommandError(event.command, InvalidFinalScript(channelId), channelUpdate) else -> { val shutdown = Shutdown(channelId, localScriptPubkey) - val newState = this.copy(localShutdown = shutdown) + val newState = this.copy(localShutdown = shutdown, closingFeerates = event.command.feerates) val actions = listOf(ChannelAction.Storage.StoreState(newState), ChannelAction.Message.Send(shutdown)) Pair(newState, actions) } @@ -1960,7 +1962,7 @@ data class Normal( if (commitments1.remoteCommit.spec.htlcs.isNotEmpty()) { // we just signed htlcs that need to be resolved now - ShuttingDown(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown) + ShuttingDown(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, closingFeerates) } else { logger.warning { "c:$channelId we have no htlcs but have not replied with our Shutdown yet, this should never happen" } val closingTxProposed = if (isFunder) { @@ -1969,13 +1971,13 @@ data class Normal( commitments1, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate, + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate), ) listOf(listOf(ClosingTxProposed(closingTx, closingSigned))) } else { listOf(listOf()) } - Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, closingTxProposed, bestUnpublishedClosingTx = null) + Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, closingTxProposed, bestUnpublishedClosingTx = null, closingFeerates) } } else { this.copy(commitments = commitments1) @@ -2041,7 +2043,7 @@ data class Normal( commitments1, localShutdown.scriptPubKey.toByteArray(), event.message.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate, + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate), ) val nextState = Negotiating( staticParams, @@ -2051,19 +2053,20 @@ data class Normal( localShutdown, event.message, listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null + bestUnpublishedClosingTx = null, + closingFeerates ) actions.addAll(listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned))) Pair(nextState, actions) } commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, event.message, closingTxProposed = listOf(listOf()), bestUnpublishedClosingTx = null) + val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, event.message, listOf(listOf()), null, closingFeerates) actions.add(ChannelAction.Storage.StoreState(nextState)) Pair(nextState, actions) } else -> { // there are some pending changes, we need to wait for them to be settled (fail/fulfill htlcs and sign fee updates) - val nextState = ShuttingDown(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, event.message) + val nextState = ShuttingDown(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, event.message, closingFeerates) actions.add(ChannelAction.Storage.StoreState(nextState)) Pair(nextState, actions) } @@ -2141,7 +2144,8 @@ data class ShuttingDown( override val currentOnChainFeerates: OnChainFeerates, override val commitments: Commitments, val localShutdown: Shutdown, - val remoteShutdown: Shutdown + val remoteShutdown: Shutdown, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) @@ -2180,7 +2184,7 @@ data class ShuttingDown( commitments1, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) ) val nextState = Negotiating( staticParams, @@ -2190,7 +2194,8 @@ data class ShuttingDown( localShutdown, remoteShutdown, listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null + bestUnpublishedClosingTx = null, + closingFeerates ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), @@ -2200,7 +2205,7 @@ data class ShuttingDown( Pair(nextState, actions) } commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, closingTxProposed = listOf(listOf()), bestUnpublishedClosingTx = null) + val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, listOf(listOf()), null, closingFeerates) val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(revocation)) Pair(nextState, actions) } @@ -2228,7 +2233,7 @@ data class ShuttingDown( commitments1, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) ) val nextState = Negotiating( staticParams, @@ -2238,13 +2243,14 @@ data class ShuttingDown( localShutdown, remoteShutdown, listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null + bestUnpublishedClosingTx = null, + closingFeerates ) actions1.addAll(listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned))) Pair(nextState, actions1) } commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, closingTxProposed = listOf(listOf()), bestUnpublishedClosingTx = null) + val nextState = Negotiating(staticParams, currentTip, currentOnChainFeerates, commitments1, localShutdown, remoteShutdown, listOf(listOf()), null, closingFeerates) actions1.add(ChannelAction.Storage.StoreState(nextState)) Pair(nextState, actions1) } @@ -2354,7 +2360,8 @@ data class Negotiating( val localShutdown: Shutdown, val remoteShutdown: Shutdown, val closingTxProposed: List>, // one list for every negotiation (there can be several in case of disconnection) - val bestUnpublishedClosingTx: ClosingTx? + val bestUnpublishedClosingTx: ClosingTx?, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { init { require(closingTxProposed.isNotEmpty()) { "there must always be a list for the current negotiation" } @@ -2366,82 +2373,108 @@ data class Negotiating( override fun processInternal(event: ChannelEvent): Pair> { return when { event is ChannelEvent.MessageReceived && event.message is ClosingSigned -> { - logger.info { "c:$channelId received closingFeeSatoshis=${event.message.feeSatoshis}" } - val checkSig = Helpers.Closing.checkClosingSignature(keyManager, commitments, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), event.message.feeSatoshis, event.message.signature) - val lastLocalClosingFee = closingTxProposed.last().lastOrNull()?.localClosingSigned?.feeSatoshis - val nextClosingFee = if (commitments.localCommit.spec.toLocal == 0.msat) { - // if we have nothing at stake there is no need to negotiate and we accept their fee right away - event.message.feeSatoshis - } else { - Helpers.Closing.nextClosingFee( - lastLocalClosingFee ?: Helpers.Closing.firstClosingFee(commitments, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, currentOnChainFeerates.mutualCloseFeerate), - event.message.feeSatoshis - ) - } - val result = checkSig.map { signedClosingTx -> // this signed closing tx matches event.message.feeSatoshis - when { - lastLocalClosingFee == event.message.feeSatoshis || lastLocalClosingFee == nextClosingFee || closingTxProposed.flatten().size >= MAX_NEGOTIATION_ITERATIONS -> { - logger.info { "c:$channelId closing tx published: closingTxId=${signedClosingTx.tx.txid}" } - val nextState = Closing( - staticParams, - currentTip, - currentOnChainFeerates, - commitments, - fundingTx = null, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOf(signedClosingTx) - ) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Blockchain.PublishTx(signedClosingTx.tx), - ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, signedClosingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(signedClosingTx.tx))) - ) - Pair(nextState, actions) - } - nextClosingFee == event.message.feeSatoshis -> { - // we have converged but they don't have our signature yet - logger.info { "c:$channelId closing tx published: closingTxId=${signedClosingTx.tx.txid}" } - val (_, closingSigned) = Helpers.Closing.makeClosingTx(keyManager, commitments, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), nextClosingFee) - val nextState = Closing( - staticParams, - currentTip, - currentOnChainFeerates, - commitments, - fundingTx = null, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = this.closingTxProposed.flatten().map { it.unsignedTx } + listOf(signedClosingTx), - mutualClosePublished = listOf(signedClosingTx) - ) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Blockchain.PublishTx(signedClosingTx.tx), - ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, signedClosingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(signedClosingTx.tx))), - ChannelAction.Message.Send(closingSigned) - ) - Pair(nextState, actions) - } - else -> { - val (closingTx, closingSigned) = Helpers.Closing.makeClosingTx(keyManager, commitments, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), nextClosingFee) - logger.info { "c:$channelId proposing closingFeeSatoshis=${closingSigned.feeSatoshis}" } - val closingProposed1 = closingTxProposed.updated( - closingTxProposed.lastIndex, - closingTxProposed.last() + listOf(ClosingTxProposed(closingTx, closingSigned)) - ) - val nextState = this.copy( - commitments = commitments.copy(remoteChannelData = event.message.channelData), - closingTxProposed = closingProposed1, - bestUnpublishedClosingTx = closingTx - ) - val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned)) - Pair(nextState, actions) + val remoteClosingFee = event.message.feeSatoshis + logger.info { "c:$channelId received closing fee=$remoteClosingFee" } + when (val result = Helpers.Closing.checkClosingSignature(keyManager, commitments, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), event.message.feeSatoshis, event.message.signature)) { + is Either.Left -> handleLocalError(event, result.value) + is Either.Right -> { + val (signedClosingTx, closingSignedRemoteFees) = result.value + val lastLocalClosingSigned = closingTxProposed.last().lastOrNull()?.localClosingSigned + when { + lastLocalClosingSigned?.feeSatoshis == remoteClosingFee -> { + logger.info { "c:$channelId they accepted our fee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, null) + } + closingTxProposed.flatten().size >= MAX_NEGOTIATION_ITERATIONS -> { + logger.warning { "c:$channelId could not agree on closing fees after $MAX_NEGOTIATION_ITERATIONS iterations, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, closingSignedRemoteFees) + } + lastLocalClosingSigned?.tlvStream?.get()?.let { it.min <= remoteClosingFee && remoteClosingFee <= it.max } == true -> { + val localFeeRange = lastLocalClosingSigned.tlvStream.get()!! + logger.info { "c:$channelId they chose closing fee=$remoteClosingFee within our fee range (min=${localFeeRange.max} max=${localFeeRange.max}), publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, closingSignedRemoteFees) + } + commitments.localCommit.spec.toLocal == 0.msat -> { + logger.info { "c:$channelId we have nothing at stake, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, closingSignedRemoteFees) + } + else -> { + val theirFeeRange = event.message.tlvStream.get() + val ourFeeRange = closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) + when { + theirFeeRange != null && !commitments.localParams.isFunder -> { + // if we are fundee and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation + // we don't care much about the closing fee since they're paying it (not us) and we can use CPFP if we want to speed up confirmation + val closingFees = Helpers.Closing.firstClosingFee(commitments, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, ourFeeRange) + val closingFee = when { + closingFees.preferred > theirFeeRange.max -> theirFeeRange.max + // if we underestimate the fee, then we're happy with whatever they propose (it will confirm more quickly and we're not paying it) + closingFees.preferred < remoteClosingFee -> remoteClosingFee + else -> closingFees.preferred + } + if (closingFee == remoteClosingFee) { + logger.info { "c:$channelId accepting their closing fee=$remoteClosingFee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, closingSignedRemoteFees) + } else { + val (closingTx, closingSigned) = Helpers.Closing.makeClosingTx( + keyManager, + commitments, + localShutdown.scriptPubKey.toByteArray(), + remoteShutdown.scriptPubKey.toByteArray(), + ClosingFees(closingFee, theirFeeRange.min, theirFeeRange.max) + ) + logger.info { "c:$channelId proposing closing fee=${closingSigned.feeSatoshis}" } + val closingProposed1 = closingTxProposed.updated( + closingTxProposed.lastIndex, + closingTxProposed.last() + listOf(ClosingTxProposed(closingTx, closingSigned)) + ) + val nextState = this.copy( + commitments = commitments.copy(remoteChannelData = event.message.channelData), + closingTxProposed = closingProposed1, + bestUnpublishedClosingTx = signedClosingTx + ) + val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned)) + Pair(nextState, actions) + } + } + else -> { + val (closingTx, closingSigned) = run { + // if we are fundee and we were waiting for them to send their first closing_signed, we compute our firstClosingFee, otherwise we use the last one we sent + val localClosingFees = Helpers.Closing.firstClosingFee(commitments, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, ourFeeRange) + val nextPreferredFee = Helpers.Closing.nextClosingFee(lastLocalClosingSigned?.feeSatoshis ?: localClosingFees.preferred, remoteClosingFee) + Helpers.Closing.makeClosingTx(keyManager, commitments, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), localClosingFees.copy(preferred = nextPreferredFee)) + } + when { + lastLocalClosingSigned?.feeSatoshis == closingSigned.feeSatoshis -> { + // next computed fee is the same than the one we previously sent (probably because of rounding) + logger.info { "c:$channelId accepting their closing fee=$remoteClosingFee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, null) + } + closingSigned.feeSatoshis == remoteClosingFee -> { + logger.info { "c:$channelId we have converged, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } + completeMutualClose(signedClosingTx, closingSigned) + } + else -> { + logger.info { "c:$channelId proposing closing fee=${closingSigned.feeSatoshis}" } + val closingProposed1 = closingTxProposed.updated( + closingTxProposed.lastIndex, + closingTxProposed.last() + listOf(ClosingTxProposed(closingTx, closingSigned)) + ) + val nextState = this.copy( + commitments = commitments.copy(remoteChannelData = event.message.channelData), + closingTxProposed = closingProposed1, + bestUnpublishedClosingTx = signedClosingTx + ) + val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned)) + Pair(nextState, actions) + } + } + } + } + } } } } - when (result) { - is Either.Right -> result.value - is Either.Left -> handleLocalError(event, result.value) - } } event is ChannelEvent.MessageReceived && event.message is Error -> handleRemoteError(event.message) event is ChannelEvent.WatchReceived -> when (val watch = event.watch) { @@ -2490,6 +2523,26 @@ data class Negotiating( return closingTxProposed.flatten().first { it.unsignedTx.tx.txid == tx.txid }.unsignedTx.copy(tx = tx) } + private fun completeMutualClose(signedClosingTx: ClosingTx, closingSigned: ClosingSigned?): Pair> { + val nextState = Closing( + staticParams, + currentTip, + currentOnChainFeerates, + commitments, + fundingTx = null, + waitingSinceBlock = currentBlockHeight.toLong(), + mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, + mutualClosePublished = listOf(signedClosingTx) + ) + val actions = buildList { + add(ChannelAction.Storage.StoreState(nextState)) + closingSigned?.let { add(ChannelAction.Message.Send(it)) } + add(ChannelAction.Blockchain.PublishTx(signedClosingTx.tx)) + add(ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, signedClosingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(signedClosingTx.tx)))) + } + return Pair(nextState, actions) + } + override fun handleLocalError(event: ChannelEvent, t: Throwable): Pair> { logger.error(t) { "c:$channelId error on event ${event::class} in state ${this::class}" } val error = Error(channelId, t.message) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommands.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommands.kt index 7e6cce837..53491ce96 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommands.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommands.kt @@ -2,9 +2,11 @@ package fr.acinq.lightning.channel import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.transactions.Transactions.weight2fee import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.FailureMessage import fr.acinq.lightning.wire.OnionRoutingPacket @@ -29,6 +31,16 @@ data class CMD_FAIL_HTLC(override val id: Long, val reason: Reason, val commit: object CMD_SIGN : Command() data class CMD_UPDATE_FEE(val feerate: FeeratePerKw, val commit: Boolean = false) : Command() +data class ClosingFees(val preferred: Satoshi, val min: Satoshi, val max: Satoshi) { + constructor(preferred: Satoshi) : this(preferred, preferred, preferred) +} + +data class ClosingFeerates(val preferred: FeeratePerKw, val min: FeeratePerKw, val max: FeeratePerKw) { + constructor(preferred: FeeratePerKw) : this(preferred, preferred / 2, preferred * 2) + + fun computeFees(closingTxWeight: Int): ClosingFees = ClosingFees(weight2fee(preferred, closingTxWeight), weight2fee(min, closingTxWeight), weight2fee(max, closingTxWeight)) +} + sealed class CloseCommand : Command() -data class CMD_CLOSE(val scriptPubKey: ByteVector?) : CloseCommand() +data class CMD_CLOSE(val scriptPubKey: ByteVector?, val feerates: ClosingFeerates?) : CloseCommand() object CMD_FORCECLOSE : CloseCommand() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index 987159fbe..f123a5cd8 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -435,14 +435,14 @@ object Helpers { }.getOrElse { null } } - fun firstClosingFee(commitments: Commitments, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, requestedFeerate: FeeratePerKw): Satoshi { + fun firstClosingFee(commitments: Commitments, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, requestedFeerate: ClosingFeerates): ClosingFees { // this is just to estimate the weight which depends on the size of the pubkey scripts val dummyClosingTx = Transactions.makeClosingTx(commitments.commitInput, localScriptPubkey, remoteScriptPubkey, commitments.localParams.isFunder, Satoshi(0), Satoshi(0), commitments.localCommit.spec) val closingWeight = Transaction.weight(Transactions.addSigs(dummyClosingTx, dummyPublicKey, commitments.remoteParams.fundingPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig).tx) - return Transactions.weight2fee(requestedFeerate, closingWeight) + return requestedFeerate.computeFees(closingWeight) } - fun firstClosingFee(commitments: Commitments, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, requestedFeerate: FeeratePerKw): Satoshi = + fun firstClosingFee(commitments: Commitments, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, requestedFeerate: ClosingFeerates): ClosingFees = firstClosingFee(commitments, localScriptPubkey.toByteArray(), remoteScriptPubkey.toByteArray(), requestedFeerate) fun nextClosingFee(localClosingFee: Satoshi, remoteClosingFee: Satoshi): Satoshi = ((localClosingFee + remoteClosingFee) / 4) * 2 @@ -452,10 +452,10 @@ object Helpers { commitments: Commitments, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, - requestedFeerate: FeeratePerKw - ): Pair { - val closingFee = firstClosingFee(commitments, localScriptPubkey, remoteScriptPubkey, requestedFeerate) - return makeClosingTx(keyManager, commitments, localScriptPubkey, remoteScriptPubkey, closingFee) + requestedFeerate: ClosingFeerates + ): Pair { + val closingFees = firstClosingFee(commitments, localScriptPubkey, remoteScriptPubkey, requestedFeerate) + return makeClosingTx(keyManager, commitments, localScriptPubkey, remoteScriptPubkey, closingFees) } fun makeClosingTx( @@ -463,14 +463,14 @@ object Helpers { commitments: Commitments, localScriptPubkey: ByteArray, remoteScriptPubkey: ByteArray, - closingFee: Satoshi - ): Pair { + closingFees: ClosingFees + ): Pair { require(isValidFinalScriptPubkey(localScriptPubkey)) { "invalid localScriptPubkey" } require(isValidFinalScriptPubkey(remoteScriptPubkey)) { "invalid remoteScriptPubkey" } val dustLimit = commitments.localParams.dustLimit.max(commitments.remoteParams.dustLimit) - val closingTx = Transactions.makeClosingTx(commitments.commitInput, localScriptPubkey, remoteScriptPubkey, commitments.localParams.isFunder, dustLimit, closingFee, commitments.localCommit.spec) + val closingTx = Transactions.makeClosingTx(commitments.commitInput, localScriptPubkey, remoteScriptPubkey, commitments.localParams.isFunder, dustLimit, closingFees.preferred, commitments.localCommit.spec) val localClosingSig = keyManager.sign(closingTx, commitments.localParams.channelKeys.fundingPrivateKey) - val closingSigned = ClosingSigned(commitments.channelId, closingFee, localClosingSig) + val closingSigned = ClosingSigned(commitments.channelId, closingFees.preferred, localClosingSig, TlvStream(listOf(ClosingSignedTlv.FeeRange(closingFees.min, closingFees.max)))) return Pair(closingTx, closingSigned) } @@ -481,12 +481,12 @@ object Helpers { remoteScriptPubkey: ByteArray, remoteClosingFee: Satoshi, remoteClosingSig: ByteVector64 - ): Either { - val (closingTx, closingSigned) = makeClosingTx(keyManager, commitments, localScriptPubkey, remoteScriptPubkey, remoteClosingFee) + ): Either> { + val (closingTx, closingSigned) = makeClosingTx(keyManager, commitments, localScriptPubkey, remoteScriptPubkey, ClosingFees(remoteClosingFee)) return if (checkClosingDustAmounts(closingTx)) { val signedClosingTx = Transactions.addSigs(closingTx, commitments.localParams.channelKeys.fundingPubKey, commitments.remoteParams.fundingPubKey, closingSigned.signature, remoteClosingSig) when (Transactions.checkSpendable(signedClosingTx)) { - is Try.Success -> Either.Right(signedClosingTx) + is Try.Success -> Either.Right(Pair(signedClosingTx, closingSigned)) is Try.Failure -> Either.Left(InvalidCloseSignature(commitments.channelId, signedClosingTx.tx)) } } else { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt index 3766588ad..1bbaf692a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt @@ -731,7 +731,8 @@ data class Normal( channelUpdate, remoteChannelUpdate, localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -759,7 +760,8 @@ data class ShuttingDown( currentOnChainFeerates.export(), commitments.export(nodeParams), localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -798,7 +800,8 @@ data class Negotiating( localShutdown, remoteShutdown, closingTxProposed.map { x -> x.map { it.export() } }, - bestUnpublishedClosingTx + bestUnpublishedClosingTx, + null ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt index f8bf4b9fb..1e21796d3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -724,7 +724,8 @@ data class Normal( channelUpdate, remoteChannelUpdate, localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -752,7 +753,8 @@ data class ShuttingDown( currentOnChainFeerates.export(), commitments.export(nodeParams), localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -791,7 +793,8 @@ data class Negotiating( localShutdown, remoteShutdown, closingTxProposed.map { x -> x.map { it.export() } }, - bestUnpublishedClosingTx + bestUnpublishedClosingTx, + null ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt index f6308fb21..d676e0caa 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -272,6 +272,13 @@ data class ChannelFeatures(@Serializable(with = ByteVectorKSerializer::class) va fun export() = fr.acinq.lightning.channel.ChannelFeatures(Features(bin.toByteArray()).activated.keys) } +@Serializable +data class ClosingFeerates(val preferred: FeeratePerKw, val min: FeeratePerKw, val max: FeeratePerKw) { + constructor(from: fr.acinq.lightning.channel.ClosingFeerates) : this(from.preferred, from.min, from.max) + + fun export() = fr.acinq.lightning.channel.ClosingFeerates(preferred, min, max) +} + @Serializable data class ClosingTxProposed(val unsignedTx: Transactions.TransactionWithInputInfo.ClosingTx, val localClosingSigned: ClosingSigned) { constructor(from: fr.acinq.lightning.channel.ClosingTxProposed) : this(from.unsignedTx, from.localClosingSigned) @@ -701,7 +708,8 @@ data class Normal( val channelUpdate: ChannelUpdate, val remoteChannelUpdate: ChannelUpdate?, val localShutdown: Shutdown?, - val remoteShutdown: Shutdown? + val remoteShutdown: Shutdown?, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { constructor(from: fr.acinq.lightning.channel.Normal) : this( StaticParams(from.staticParams), @@ -714,7 +722,8 @@ data class Normal( from.channelUpdate, from.remoteChannelUpdate, from.localShutdown, - from.remoteShutdown + from.remoteShutdown, + from.closingFeerates?.let { ClosingFeerates(it) } ) override fun export(nodeParams: NodeParams) = fr.acinq.lightning.channel.Normal( @@ -728,7 +737,8 @@ data class Normal( channelUpdate, remoteChannelUpdate, localShutdown, - remoteShutdown + remoteShutdown, + closingFeerates?.export() ) } @@ -739,7 +749,8 @@ data class ShuttingDown( override val currentOnChainFeerates: OnChainFeerates, override val commitments: Commitments, val localShutdown: Shutdown, - val remoteShutdown: Shutdown + val remoteShutdown: Shutdown, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { constructor(from: fr.acinq.lightning.channel.ShuttingDown) : this( StaticParams(from.staticParams), @@ -747,7 +758,8 @@ data class ShuttingDown( OnChainFeerates(from.currentOnChainFeerates), Commitments(from.commitments), from.localShutdown, - from.remoteShutdown + from.remoteShutdown, + from.closingFeerates?.let { ClosingFeerates(it) } ) override fun export(nodeParams: NodeParams) = fr.acinq.lightning.channel.ShuttingDown( @@ -756,7 +768,8 @@ data class ShuttingDown( currentOnChainFeerates.export(), commitments.export(nodeParams), localShutdown, - remoteShutdown + remoteShutdown, + closingFeerates?.export() ) } @@ -769,7 +782,8 @@ data class Negotiating( val localShutdown: Shutdown, val remoteShutdown: Shutdown, val closingTxProposed: List>, - val bestUnpublishedClosingTx: Transactions.TransactionWithInputInfo.ClosingTx? + val bestUnpublishedClosingTx: Transactions.TransactionWithInputInfo.ClosingTx?, + val closingFeerates: ClosingFeerates? ) : ChannelStateWithCommitments() { init { require(closingTxProposed.isNotEmpty()) { "there must always be a list for the current negotiation" } @@ -784,7 +798,8 @@ data class Negotiating( from.localShutdown, from.remoteShutdown, from.closingTxProposed.map { x -> x.map { ClosingTxProposed(it) } }, - from.bestUnpublishedClosingTx + from.bestUnpublishedClosingTx, + from.closingFeerates?.let { ClosingFeerates(it) } ) override fun export(nodeParams: NodeParams) = fr.acinq.lightning.channel.Negotiating( @@ -795,7 +810,8 @@ data class Negotiating( localShutdown, remoteShutdown, closingTxProposed.map { x -> x.map { it.export() } }, - bestUnpublishedClosingTx + bestUnpublishedClosingTx, + closingFeerates?.export() ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 099eace13..eb5b0e5c1 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -219,6 +219,21 @@ sealed class ShutdownTlv : Tlv { @Serializable sealed class ClosingSignedTlv : Tlv { + @Serializable + data class FeeRange(@Contextual val min: Satoshi, @Contextual val max: Satoshi) : ClosingSignedTlv() { + override val tag: Long get() = FeeRange.tag + + override fun write(out: Output) { + LightningCodecs.writeU64(min.toLong(), out) + LightningCodecs.writeU64(max.toLong(), out) + } + + companion object : TlvValueReader { + const val tag: Long = 1 + override fun read(input: Input): FeeRange = FeeRange(Satoshi(LightningCodecs.u64(input)), Satoshi(LightningCodecs.u64(input))) + } + } + @Serializable data class ChannelData(@Contextual val ecb: EncryptedChannelData) : ClosingSignedTlv() { override val tag: Long get() = ChannelData.tag diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index afc52fd40..e5a27fbb4 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -1050,7 +1050,10 @@ data class ClosingSigned( const val type: Long = 39 @Suppress("UNCHECKED_CAST") - val readers = mapOf(ClosingSignedTlv.ChannelData.tag to ClosingSignedTlv.ChannelData.Companion as TlvValueReader) + val readers = mapOf( + ClosingSignedTlv.FeeRange.tag to ClosingSignedTlv.FeeRange.Companion as TlvValueReader, + ClosingSignedTlv.ChannelData.tag to ClosingSignedTlv.ChannelData.Companion as TlvValueReader + ) override fun read(input: Input): ClosingSigned { return ClosingSigned( diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index 1441267e1..4c61d1060 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -180,32 +180,38 @@ object TestsHelper { return Pair(alice as Normal, bob as Normal) } - fun mutualClose( - alice: Normal, - bob: Normal, - tweakFees: Boolean = false, - scriptPubKey: ByteVector? = null - ): Triple { - val alice1 = alice.updateFeerate(if (tweakFees) FeeratePerKw(4_319.sat) else FeeratePerKw(10_000.sat)) - val bob1 = bob.updateFeerate(if (tweakFees) FeeratePerKw(4_319.sat) else FeeratePerKw(10_000.sat)) - - // Bob is fundee and initiates the closing - val (bob2, actions) = bob1.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(scriptPubKey))) - assertTrue(bob2 is Normal) - val shutdown = actions.findOutgoingMessage() - - // Alice is funder, she will sign the first closing tx - val (alice2, actions1) = alice1.process(ChannelEvent.MessageReceived(shutdown)) + fun mutualCloseAlice(alice: Normal, bob: Normal, scriptPubKey: ByteVector? = null, feerates: ClosingFeerates? = null): Triple { + val (alice1, actionsAlice1) = alice.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(scriptPubKey, feerates))) + assertTrue(alice1 is Normal) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + assertNull(actionsAlice1.findOutgoingMessageOpt()) + + val (bob1, actionsBob1) = bob.process(ChannelEvent.MessageReceived(shutdownAlice)) + assertTrue(bob1 is Negotiating) + val shutdownBob = actionsBob1.findOutgoingMessage() + assertNull(actionsBob1.findOutgoingMessageOpt()) + + val (alice2, actionsAlice2) = alice1.process(ChannelEvent.MessageReceived(shutdownBob)) assertTrue(alice2 is Negotiating) - val shutdown1 = actions1.findOutgoingMessage() - val closingSigned = actions1.findOutgoingMessage() - - val alice3 = alice2.updateFeerate(if (tweakFees) FeeratePerKw(4_316.sat) else FeeratePerKw(5_000.sat)) - val bob3 = bob2.updateFeerate(if (tweakFees) FeeratePerKw(4_316.sat) else FeeratePerKw(5_000.sat)) + val closingSignedAlice = actionsAlice2.findOutgoingMessage() + return Triple(alice2, bob1, closingSignedAlice) + } - val (bob4, _) = bob3.process(ChannelEvent.MessageReceived(shutdown1)) - assertTrue(bob4 is Negotiating) - return Triple(alice3, bob4, closingSigned) + fun mutualCloseBob(alice: Normal, bob: Normal, scriptPubKey: ByteVector? = null, feerates: ClosingFeerates? = null): Triple { + val (bob1, actionsBob1) = bob.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(scriptPubKey, feerates))) + assertTrue(bob1 is Normal) + val shutdownBob = actionsBob1.findOutgoingMessage() + assertNull(actionsBob1.findOutgoingMessageOpt()) + + val (alice1, actionsAlice1) = alice.process(ChannelEvent.MessageReceived(shutdownBob)) + assertTrue(alice1 is Negotiating) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + val closingSignedAlice = actionsAlice1.findOutgoingMessage() + + val (bob2, actionsBob2) = bob1.process(ChannelEvent.MessageReceived(shutdownAlice)) + assertTrue(bob2 is Negotiating) + assertNull(actionsBob2.findOutgoingMessageOpt()) + return Triple(alice1, bob2, closingSignedAlice) } fun localClose(s: ChannelState): Pair { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt index 48d6a8528..8d4af60d2 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -19,7 +19,8 @@ import fr.acinq.lightning.channel.TestsHelper.htlcSuccessTxs import fr.acinq.lightning.channel.TestsHelper.htlcTimeoutTxs import fr.acinq.lightning.channel.TestsHelper.localClose import fr.acinq.lightning.channel.TestsHelper.makeCmdAdd -import fr.acinq.lightning.channel.TestsHelper.mutualClose +import fr.acinq.lightning.channel.TestsHelper.mutualCloseAlice +import fr.acinq.lightning.channel.TestsHelper.mutualCloseBob import fr.acinq.lightning.channel.TestsHelper.processEx import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.channel.TestsHelper.remoteClose @@ -34,24 +35,6 @@ import kotlin.test.* class ClosingTestsCommon : LightningTestSuite() { - @Test - fun `start fee negotiation from configured block target`() { - val (alice, bob) = reachNormal() - val (alice1, actions) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) - val shutdown = actions.findOutgoingMessage() - val (_, actions1) = bob.processEx(ChannelEvent.MessageReceived(shutdown)) - val shutdown1 = actions1.findOutgoingMessage() - val (alice2, actions2) = alice1.processEx(ChannelEvent.MessageReceived(shutdown1)) - val closingSigned = actions2.findOutgoingMessage() - val expectedProposedFee = Helpers.Closing.firstClosingFee( - (alice2 as Negotiating).commitments, - alice2.localShutdown.scriptPubKey.toByteArray(), - alice2.remoteShutdown.scriptPubKey.toByteArray(), - alice2.currentOnChainFeerates.mutualCloseFeerate - ) - assertEquals(closingSigned.feeSatoshis, expectedProposedFee) - } - @Test fun `recv CMD_ADD_HTLC`() { val (alice, _, _) = initMutualClose() @@ -80,43 +63,39 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv BITCOIN_FUNDING_SPENT (mutual close before converging)`() { val (alice0, bob0) = reachNormal() - // alice initiates a closing - val (alice1, aliceActions1) = alice0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + // alice initiates a closing with a low fee + val (alice1, aliceActions1) = alice0.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, ClosingFeerates(FeeratePerKw(500.sat), FeeratePerKw(250.sat), FeeratePerKw(1000.sat))))) val shutdown0 = aliceActions1.findOutgoingMessage() val (bob1, bobActions1) = bob0.processEx(ChannelEvent.MessageReceived(shutdown0)) + assertTrue(bob1 is Negotiating) val shutdown1 = bobActions1.findOutgoingMessage() - val (alice2, aliceActions2) = alice1.processEx(ChannelEvent.MessageReceived(shutdown1)) - - // agreeing on a closing fee + val (alice2, aliceActions2) = alice1.process(ChannelEvent.MessageReceived(shutdown1)) + assertTrue(alice2 is Negotiating) val closingSigned0 = aliceActions2.findOutgoingMessage() - val aliceCloseFee = closingSigned0.feeSatoshis - val bob2 = (bob1 as Negotiating).updateFeerate(FeeratePerKw(5_000.sat)) - val (_, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(closingSigned0)) - val closingSigned1 = bobActions3.findOutgoingMessage() - val bobCloseFee = closingSigned1.feeSatoshis - val (alice3, _) = alice2.processEx(ChannelEvent.MessageReceived(closingSigned1)) - - // they don't converge yet, but alice has a publishable commit tx now - assertNotEquals(aliceCloseFee, bobCloseFee) - val mutualCloseTx = (alice3 as Negotiating).bestUnpublishedClosingTx + + // they don't converge yet, but bob has a publishable commit tx now + val (bob2, bobActions2) = bob1.processEx(ChannelEvent.MessageReceived(closingSigned0)) + assertTrue(bob2 is Negotiating) + val mutualCloseTx = bob2.bestUnpublishedClosingTx assertNotNull(mutualCloseTx) + val closingSigned1 = bobActions2.findOutgoingMessage() + assertNotEquals(closingSigned0.feeSatoshis, closingSigned1.feeSatoshis) - // let's make alice publish this closing tx - val (alice4, aliceActions4) = alice3.processEx(ChannelEvent.MessageReceived(Error(ByteVector32.Zeroes, ""))) - assertTrue { alice4 is Closing } - assertEquals(ChannelAction.Blockchain.PublishTx(mutualCloseTx.tx), aliceActions4.filterIsInstance().first()) - assertEquals(mutualCloseTx, (alice4 as Closing).mutualClosePublished.last()) - aliceActions4.has() + // let's make bob publish this closing tx + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(Error(ByteVector32.Zeroes, ""))) + assertTrue(bob3 is Closing) + assertEquals(ChannelAction.Blockchain.PublishTx(mutualCloseTx.tx), bobActions3.filterIsInstance().first()) + assertEquals(mutualCloseTx, bob3.mutualClosePublished.last()) + bobActions3.has() // actual test starts here - val (alice5, _) = alice4.processEx(ChannelEvent.WatchReceived(WatchEventSpent(ByteVector32.Zeroes, BITCOIN_FUNDING_SPENT, mutualCloseTx.tx))) - val (alice6, aliceActions6) = alice5.processEx(ChannelEvent.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) - - assertTrue { alice6 is Closed } - val storeChannelClosed = aliceActions6.filterIsInstance().firstOrNull() + val (bob4, _) = bob3.processEx(ChannelEvent.WatchReceived(WatchEventSpent(ByteVector32.Zeroes, BITCOIN_FUNDING_SPENT, mutualCloseTx.tx))) + val (bob5, bobActions5) = bob4.processEx(ChannelEvent.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) + assertTrue(bob5 is Closed) + val storeChannelClosed = bobActions5.filterIsInstance().firstOrNull() assertNotNull(storeChannelClosed) - assertTrue { storeChannelClosed.closingType == ChannelClosingType.Mutual } - assertTrue { storeChannelClosed.txids == listOf(mutualCloseTx.tx.txid) } + assertEquals(storeChannelClosed.closingType, ChannelClosingType.Mutual) + assertEquals(storeChannelClosed.txids, listOf(mutualCloseTx.tx.txid)) } @Test @@ -126,11 +105,11 @@ class ClosingTestsCommon : LightningTestSuite() { // actual test starts here val (alice1, actions1) = alice0.processEx(ChannelEvent.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) - assertTrue { alice1 is Closed } + assertTrue(alice1 is Closed) val storeChannelClosed = actions1.filterIsInstance().firstOrNull() assertNotNull(storeChannelClosed) - assertTrue { storeChannelClosed.closingType == ChannelClosingType.Mutual } - assertTrue { storeChannelClosed.txids == listOf(mutualCloseTx.tx.txid) } + assertEquals(storeChannelClosed.closingType, ChannelClosingType.Mutual) + assertEquals(storeChannelClosed.txids, listOf(mutualCloseTx.tx.txid)) } @Test @@ -140,23 +119,16 @@ class ClosingTestsCommon : LightningTestSuite() { val bobFinalScript = Script.write(Script.pay2pkh(pubKey)).toByteVector() val (alice1, bob1) = reachNormal() - val (alice2, bob2, aliceClosingSigned1) = mutualClose(alice1, bob1, tweakFees = true, scriptPubKey = bobFinalScript) + val (_, bob2, aliceClosingSigned) = mutualCloseBob(alice1, bob1, scriptPubKey = bobFinalScript) - val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceClosingSigned1)) + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceClosingSigned)) + assertTrue(bob3 is Closing) val bobClosingSigned = bobActions3.findOutgoingMessageOpt() assertNotNull(bobClosingSigned) - - val (alice4, aliceActions4) = alice2.processEx(ChannelEvent.MessageReceived(bobClosingSigned)) - assertTrue { alice4 is Closing } - val aliceClosingSigned2 = aliceActions4.findOutgoingMessageOpt() - assertNotNull(aliceClosingSigned2) - - val (bob5, bobActions5) = bob3.processEx(ChannelEvent.MessageReceived(aliceClosingSigned2)) - assertTrue { bob5 is Closing } - val storeChannelClosing = bobActions5.filterIsInstance().firstOrNull() + val storeChannelClosing = bobActions3.filterIsInstance().firstOrNull() assertNotNull(storeChannelClosing) - assertFalse { storeChannelClosing.isSentToDefaultAddress } - assertTrue { storeChannelClosing.closingAddress == bobBtcAddr } + assertFalse(storeChannelClosing.isSentToDefaultAddress) + assertEquals(storeChannelClosing.closingAddress, bobBtcAddr) } @Test @@ -1590,7 +1562,7 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (alice0, _, _) = initMutualClose() - val cmdClose = CMD_CLOSE(null) + val cmdClose = CMD_CLOSE(null, null) val (_, actions) = alice0.processEx(ChannelEvent.ExecuteCommand(cmdClose)) val commandError = actions.filterIsInstance().first() assertEquals(cmdClose, commandError.cmd) @@ -1763,7 +1735,7 @@ class ClosingTestsCommon : LightningTestSuite() { }.flatten() } - val (alice1, bob1, aliceCloseSig) = mutualClose(mutableAlice, mutableBob) + val (alice1, bob1, aliceCloseSig) = mutualCloseAlice(mutableAlice, mutableBob) val (alice2, bob2) = NegotiatingTestsCommon.converge(alice1, bob1, aliceCloseSig) ?: error("converge should not return null") return Triple(alice2, bob2, bobCommitTxs) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt index f0c46a4bc..41060f93f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt @@ -5,9 +5,11 @@ import fr.acinq.lightning.Feature import fr.acinq.lightning.Lightning.randomKey import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.* +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.TestsHelper.makeCmdAdd -import fr.acinq.lightning.channel.TestsHelper.mutualClose +import fr.acinq.lightning.channel.TestsHelper.mutualCloseAlice +import fr.acinq.lightning.channel.TestsHelper.mutualCloseBob import fr.acinq.lightning.channel.TestsHelper.processEx import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.tests.TestConstants @@ -15,124 +17,323 @@ import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat import fr.acinq.lightning.utils.sat import fr.acinq.lightning.utils.toByteVector -import fr.acinq.lightning.wire.ClosingSigned -import fr.acinq.lightning.wire.Error -import fr.acinq.lightning.wire.Shutdown -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import fr.acinq.lightning.wire.* +import kotlin.test.* class NegotiatingTestsCommon : LightningTestSuite() { @Test - fun `correctly sign and detect closing tx`() { - // we're fundee here, not funder !! - val (bob, alice) = reachNormal() - val priv = randomKey() + fun `recv CMD_ADD_HTLC`() { + val (alice, _, _) = init() + val (_, add) = makeCmdAdd(500_000.msat, alice.staticParams.remoteNodeId, TestConstants.defaultBlockHeight.toLong()) + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(add)) + assertTrue(alice1 is Negotiating) + assertEquals(1, actions1.size) + actions1.hasCommandError() + } - // Alice initiates a mutual close with a custom final script - val finalScript = Script.write(Script.pay2pkh(priv.publicKey())).toByteVector() - val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(finalScript))) - val shutdownA = actions1.findOutgoingMessage() + private fun testClosingSignedDifferentFees(alice: Normal, bob: Normal, bobInitiates: Boolean = false) { + // alice and bob see different on-chain feerates + val alice1 = alice.updateFeerate(FeeratePerKw(5_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(7_500.sat)) + val (alice2, bob2, aliceCloseSig1) = if (bobInitiates) mutualCloseBob(alice1, bob1) else mutualCloseAlice(alice1, bob1) - // Bob replies with Shutdown + ClosingSigned - val (bob1, actions2) = bob.processEx(ChannelEvent.MessageReceived(shutdownA)) - val shutdownB = actions2.findOutgoingMessage() - val closingSignedB = actions2.findOutgoingMessage() + // alice is funder so she initiates the negotiation + assertEquals(aliceCloseSig1.feeSatoshis, 3370.sat) // matches a feerate of 5000 sat/kw + val aliceFeeRange = aliceCloseSig1.tlvStream.get() + assertNotNull(aliceFeeRange) + assertTrue(aliceFeeRange.min < aliceCloseSig1.feeSatoshis) + assertTrue(aliceCloseSig1.feeSatoshis < aliceFeeRange.max) + assertEquals(alice2.closingTxProposed.size, 1) + assertEquals(alice2.closingTxProposed.last().size, 1) + assertNull(alice2.bestUnpublishedClosingTx) - // Alice agrees with Bob's closing fee, publishes her closing tx and replies with her own ClosingSigned - val (alice2, _) = alice1.processEx(ChannelEvent.MessageReceived(shutdownB)) - val (alice3, actions4) = alice2.processEx(ChannelEvent.MessageReceived(closingSignedB)) - assertTrue(alice3 is Closing) - val closingTxA = actions4.filterIsInstance().first().tx - val closingSignedA = actions4.findOutgoingMessage() - val watch = actions4.findWatch() - assertEquals(watch.txId, closingTxA.txid) - - val fundingTx = Transaction( - version = 2, - txIn = listOf(TxIn(OutPoint(ByteVector32.Zeroes, 0), TxIn.SEQUENCE_FINAL)), - txOut = listOf(bob.commitments.commitInput.txOut), - lockTime = 0 - ) - assertEquals(fundingTx.txid, closingTxA.txIn[0].outPoint.txid) - // check that our closing tx is correctly signed - Transaction.correctlySpends(closingTxA, fundingTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // bob answers with a counter proposition in alice's fee range + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig1)) + assertTrue(bob3 is Negotiating) + val bobCloseSig1 = bobActions3.findOutgoingMessage() + assertTrue(aliceFeeRange.min < bobCloseSig1.feeSatoshis) + assertTrue(bobCloseSig1.feeSatoshis < aliceFeeRange.max) + assertNotNull(bobCloseSig1.tlvStream.get()) + assertTrue(aliceCloseSig1.feeSatoshis < bobCloseSig1.feeSatoshis) + assertNotNull(bob3.bestUnpublishedClosingTx) - // Bob published his closing tx (which should be the same as Alice's !!!) - val (bob2, actions5) = bob1.processEx(ChannelEvent.MessageReceived(closingSignedA)) - assertTrue(bob2 is Closing) - val closingTxB = actions5.filterIsInstance().first().tx - assertEquals(closingTxA, closingTxB) + // alice accepts this proposition + val (alice3, aliceActions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig1)) + assertTrue(alice3 is Closing) + val mutualCloseTx = aliceActions3.findTxs().first() + assertEquals(aliceActions3.findWatch().txId, mutualCloseTx.txid) + assertEquals(mutualCloseTx.txOut.size, 2) // NB: anchors are removed from the closing tx + val aliceCloseSig2 = aliceActions3.findOutgoingMessage() + assertEquals(aliceCloseSig2.feeSatoshis, bobCloseSig1.feeSatoshis) + val (bob4, bobActions4) = bob3.processEx(ChannelEvent.MessageReceived(aliceCloseSig2)) + assertTrue(bob4 is Closing) + bobActions4.hasTx(mutualCloseTx) + assertEquals(bobActions4.findWatch().txId, mutualCloseTx.txid) + assertEquals(alice3.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) + assertEquals(bob4.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) + } - // Alice sees Bob's closing tx (which should be the same as the one she published) - val (alice4, _) = alice3.processEx(ChannelEvent.WatchReceived(WatchEventSpent(alice3.channelId, BITCOIN_FUNDING_SPENT, closingTxB))) - assertTrue(alice4 is Closing) + @Test + fun `recv ClosingSigned (theirCloseFee != ourCloseFee)`() { + val (alice, bob) = reachNormal() + testClosingSignedDifferentFees(alice, bob) + } - val (alice5, _) = alice4.processEx(ChannelEvent.WatchReceived(WatchEventConfirmed(alice3.channelId, BITCOIN_TX_CONFIRMED(closingTxA), 144, 0, closingTxA))) - assertTrue(alice5 is Closed) + @Test + fun `recv ClosingSigned (theirCloseFee != ourCloseFee, bob starts closing)`() { + val (alice, bob) = reachNormal() + testClosingSignedDifferentFees(alice, bob, bobInitiates = true) } @Test - fun `recv CMD_ADD_HTLC`() { - val (alice, _, _) = init() - val (_, add) = makeCmdAdd(500_000.msat, alice.staticParams.remoteNodeId, TestConstants.defaultBlockHeight.toLong()) - val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(add)) - assertTrue(alice1 is Negotiating) - assertEquals(1, actions1.size) - actions1.hasCommandError() + fun `recv ClosingSigned (theirMinCloseFee greater than ourCloseFee)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(10_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(2_500.sat)) + + val (_, bob2, aliceCloseSig) = mutualCloseAlice(alice1, bob1) + val (bob3, actions) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) + assertTrue(bob3 is Closing) + val bobCloseSig = actions.findOutgoingMessage() + assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.feeSatoshis) } @Test - fun `recv ClosingSigned (theirCloseFee != ourCloseFee)`() { - val (alice, bob, aliceCloseSig) = init() - val (_, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) - // Bob answers with a counter proposition + fun `recv ClosingSigned (theirMaxCloseFee smaller than ourCloseFee)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(5_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(20_000.sat)) + + val (_, bob2, aliceCloseSig) = mutualCloseAlice(alice1, bob1) + val (_, actions) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) val bobCloseSig = actions.findOutgoingMessage() - assertTrue { aliceCloseSig.feeSatoshis > bobCloseSig.feeSatoshis } - val (alice1, actions1) = alice.processEx(ChannelEvent.MessageReceived(bobCloseSig)) - val aliceCloseSig1 = actions1.findOutgoingMessage() - // BOLT 2: If the receiver [doesn't agree with the fee] it SHOULD propose a value strictly between the received fee-satoshis and its previously-sent fee-satoshis - assertTrue { aliceCloseSig1.feeSatoshis < aliceCloseSig.feeSatoshis && aliceCloseSig1.feeSatoshis > bobCloseSig.feeSatoshis } - assertEquals((alice1 as Negotiating).closingTxProposed.last().map { it.localClosingSigned }, alice.closingTxProposed.last().map { it.localClosingSigned } + listOf(aliceCloseSig1)) + assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.tlvStream.get()!!.max) + } + + private fun testClosingSignedSameFees(alice: Normal, bob: Normal, bobInitiates: Boolean = false) { + val alice1 = alice.updateFeerate(FeeratePerKw(5_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(5_000.sat)) + val (alice2, bob2, aliceCloseSig1) = if (bobInitiates) mutualCloseBob(alice1, bob1) else mutualCloseAlice(alice1, bob1) + + // alice is funder so she initiates the negotiation + assertEquals(aliceCloseSig1.feeSatoshis, 3370.sat) // matches a feerate of 5000 sat/kw + val aliceFeeRange = aliceCloseSig1.tlvStream.get() + assertNotNull(aliceFeeRange) + + // bob agrees with that proposal + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig1)) + assertTrue(bob3 is Closing) + val bobCloseSig1 = bobActions3.findOutgoingMessage() + assertNotNull(bobCloseSig1.tlvStream.get()) + assertEquals(aliceCloseSig1.feeSatoshis, bobCloseSig1.feeSatoshis) + val mutualCloseTx = bobActions3.findTxs().first() + assertEquals(mutualCloseTx.txOut.size, 2) // NB: anchors are removed from the closing tx + + val (alice3, aliceActions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig1)) + assertTrue(alice3 is Closing) + aliceActions3.hasTx(mutualCloseTx) } @Test fun `recv ClosingSigned (theirCloseFee == ourCloseFee)`() { - val (alice, bob, aliceCloseSig) = init() - assertTrue { converge(alice, bob, aliceCloseSig) != null } + val (alice, bob) = reachNormal() + testClosingSignedSameFees(alice, bob) + } + + @Test + fun `recv ClosingSigned (theirCloseFee == ourCloseFee, bob starts closing)`() { + val (alice, bob) = reachNormal() + testClosingSignedSameFees(alice, bob, bobInitiates = true) + } + + @Test + fun `override on-chain fee estimator (funder)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(10_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) + + // alice initiates the negotiation with a very low feerate + val (alice2, bob2, aliceCloseSig) = mutualCloseAlice(alice1, bob1, feerates = ClosingFeerates(FeeratePerKw(2_500.sat), FeeratePerKw(2_000.sat), FeeratePerKw(3_000.sat))) + assertEquals(aliceCloseSig.feeSatoshis, 1685.sat) + assertEquals(aliceCloseSig.tlvStream.get(), ClosingSignedTlv.FeeRange(1348.sat, 2022.sat)) + + // bob chooses alice's highest fee + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) + val bobCloseSig = bobActions3.findOutgoingMessage() + assertEquals(bobCloseSig.feeSatoshis, 2022.sat) + + // alice accepts this proposition + val (alice3, aliceActions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig)) + assertTrue(alice3 is Closing) + val mutualCloseTx = aliceActions3.findTxs().first() + val aliceCloseSig2 = aliceActions3.findOutgoingMessage() + assertEquals(aliceCloseSig2.feeSatoshis, 2022.sat) + + val (bob4, bobActions4) = bob3.processEx(ChannelEvent.MessageReceived(aliceCloseSig2)) + assertTrue(bob4 is Closing) + bobActions4.hasTx(mutualCloseTx) } @Test - fun `recv ClosingSigned (theirCloseFee == ourCloseFee, different fee parameters)`() { - val (alice, bob, aliceCloseSig) = init(tweakFees = true) - assertTrue { converge(alice, bob, aliceCloseSig) != null } + fun `override on-chain fee estimator (fundee)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(10_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) + + // alice is funder, so bob's override will simply be ignored + val (alice2, bob2, aliceCloseSig) = mutualCloseBob(alice1, bob1, feerates = ClosingFeerates(FeeratePerKw(2_500.sat), FeeratePerKw(2_000.sat), FeeratePerKw(3_000.sat))) + assertEquals(aliceCloseSig.feeSatoshis, 6740.sat) // matches a feerate of 10 000 sat/kw + + // bob directly agrees because their fee estimator matches + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) + assertTrue(bob3 is Closing) + val mutualCloseTx = bobActions3.findTxs().first() + val bobCloseSig = bobActions3.findOutgoingMessage() + assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.feeSatoshis) + + // alice accepts this proposition + val (alice3, aliceActions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig)) + assertTrue(alice3 is Closing) + aliceActions3.hasTx(mutualCloseTx) } @Test fun `recv ClosingSigned (nothing at stake)`() { - val (alice, bob, aliceCloseSig) = init(pushMsat = 0.msat) + val (alice, bob) = reachNormal(pushMsat = 0.msat) + val alice1 = alice.updateFeerate(FeeratePerKw(5_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) + // Bob has nothing at stake - val (bob1, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) - assertTrue(bob1 is Closing) - val mutualCloseTxBob = actions.findTxs().first() - val bobCloseSig = actions.findOutgoingMessage() - assertEquals(aliceCloseSig.feeSatoshis, bobCloseSig.feeSatoshis) - val (alice1, actions1) = alice.processEx(ChannelEvent.MessageReceived(bobCloseSig)) - assertTrue(alice1 is Closing) - val mutualCloseTxAlice = actions1.findTxs().first() - assertEquals(mutualCloseTxAlice, mutualCloseTxBob) - assertEquals(actions.findWatches().map { it.event }, listOf(BITCOIN_TX_CONFIRMED(mutualCloseTxBob))) - assertEquals(actions1.findWatches().map { it.event }, listOf(BITCOIN_TX_CONFIRMED(mutualCloseTxBob))) - assertEquals(bob1.mutualClosePublished.map { it.tx }, listOf(mutualCloseTxBob)) - assertEquals(alice1.mutualClosePublished.map { it.tx }, listOf(mutualCloseTxBob)) + val (_, bob2, aliceCloseSig) = mutualCloseBob(alice1, bob1) + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) + assertTrue(bob3 is Closing) + val mutualCloseTx = bobActions3.findTxs().first() + assertEquals(bob3.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) + assertEquals(bobActions3.findWatches().map { it.event }, listOf(BITCOIN_TX_CONFIRMED(mutualCloseTx))) + } + + @Test + fun `recv ClosingSigned (other side ignores our fee range, funder)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(1_000.sat)) + val (alice2, bob2, aliceCloseSig1) = mutualCloseAlice(alice1, bob) + val aliceFeeRange = aliceCloseSig1.tlvStream.get() + assertNotNull(aliceFeeRange) + assertEquals(aliceCloseSig1.feeSatoshis, 674.sat) + assertEquals(aliceFeeRange.max, 1348.sat) + assertEquals(alice2.closingTxProposed.last().size, 1) + assertNull(alice2.bestUnpublishedClosingTx) + + // bob makes a proposal outside our fee range + val (_, bobCloseSig1) = makeLegacyClosingSigned(alice2, bob2, 2_500.sat) + val (alice3, actions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig1)) + assertTrue(alice3 is Negotiating) + val aliceCloseSig2 = actions3.findOutgoingMessage() + assertTrue(aliceCloseSig1.feeSatoshis < aliceCloseSig2.feeSatoshis) + assertTrue(aliceCloseSig2.feeSatoshis < 1600.sat) + assertEquals(alice3.closingTxProposed.last().size, 2) + assertNotNull(alice3.bestUnpublishedClosingTx) + + val (_, bobCloseSig2) = makeLegacyClosingSigned(alice2, bob2, 2_000.sat) + val (alice4, actions4) = alice3.processEx(ChannelEvent.MessageReceived(bobCloseSig2)) + assertTrue(alice4 is Negotiating) + val aliceCloseSig3 = actions4.findOutgoingMessage() + assertTrue(aliceCloseSig2.feeSatoshis < aliceCloseSig3.feeSatoshis) + assertTrue(aliceCloseSig3.feeSatoshis < 1800.sat) + assertEquals(alice4.closingTxProposed.last().size, 3) + assertNotNull(alice4.bestUnpublishedClosingTx) + + val (_, bobCloseSig3) = makeLegacyClosingSigned(alice2, bob2, 1_800.sat) + val (alice5, actions5) = alice4.processEx(ChannelEvent.MessageReceived(bobCloseSig3)) + assertTrue(alice5 is Negotiating) + val aliceCloseSig4 = actions5.findOutgoingMessage() + assertTrue(aliceCloseSig3.feeSatoshis < aliceCloseSig4.feeSatoshis) + assertTrue(aliceCloseSig4.feeSatoshis < 1800.sat) + assertEquals(alice5.closingTxProposed.last().size, 4) + assertNotNull(alice5.bestUnpublishedClosingTx) + + val (_, bobCloseSig4) = makeLegacyClosingSigned(alice2, bob2, aliceCloseSig4.feeSatoshis) + val (alice6, actions6) = alice5.processEx(ChannelEvent.MessageReceived(bobCloseSig4)) + assertTrue(alice6 is Closing) + val mutualCloseTx = actions6.findTxs().first() + assertEquals(alice6.mutualClosePublished.size, 1) + assertEquals(mutualCloseTx, alice6.mutualClosePublished.first().tx) + } + + @Test + fun `recv ClosingSigned (other side ignores our fee range, fundee)`() { + val (alice, bob) = reachNormal() + val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) + val (alice2, bob2, _) = mutualCloseBob(alice, bob1) + + // alice starts with a very low proposal + val (aliceCloseSig1, _) = makeLegacyClosingSigned(alice2, bob2, 500.sat) + val (bob3, actions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig1)) + assertTrue(bob3 is Negotiating) + val bobCloseSig1 = actions3.findOutgoingMessage() + assertTrue(3000.sat < bobCloseSig1.feeSatoshis) + assertEquals(bob3.closingTxProposed.last().size, 1) + assertNotNull(bob3.bestUnpublishedClosingTx) + + val (aliceCloseSig2, _) = makeLegacyClosingSigned(alice2, bob2, 750.sat) + val (bob4, actions4) = bob3.processEx(ChannelEvent.MessageReceived(aliceCloseSig2)) + assertTrue(bob4 is Negotiating) + val bobCloseSig2 = actions4.findOutgoingMessage() + assertTrue(2000.sat < bobCloseSig2.feeSatoshis) + assertEquals(bob4.closingTxProposed.last().size, 2) + assertNotNull(bob4.bestUnpublishedClosingTx) + + val (aliceCloseSig3, _) = makeLegacyClosingSigned(alice2, bob2, 1000.sat) + val (bob5, actions5) = bob4.processEx(ChannelEvent.MessageReceived(aliceCloseSig3)) + assertTrue(bob5 is Negotiating) + val bobCloseSig3 = actions5.findOutgoingMessage() + assertTrue(1500.sat < bobCloseSig3.feeSatoshis) + assertEquals(bob5.closingTxProposed.last().size, 3) + assertNotNull(bob5.bestUnpublishedClosingTx) + + val (aliceCloseSig4, _) = makeLegacyClosingSigned(alice2, bob2, 1300.sat) + val (bob6, actions6) = bob5.processEx(ChannelEvent.MessageReceived(aliceCloseSig4)) + assertTrue(bob6 is Negotiating) + val bobCloseSig4 = actions6.findOutgoingMessage() + assertTrue(1300.sat < bobCloseSig4.feeSatoshis) + assertEquals(bob6.closingTxProposed.last().size, 4) + assertNotNull(bob6.bestUnpublishedClosingTx) + + val (aliceCloseSig5, _) = makeLegacyClosingSigned(alice2, bob2, bobCloseSig4.feeSatoshis) + val (bob7, actions7) = bob6.processEx(ChannelEvent.MessageReceived(aliceCloseSig5)) + assertTrue(bob7 is Closing) + val mutualCloseTx = actions7.findTxs().first() + assertEquals(bob7.mutualClosePublished.size, 1) + assertEquals(mutualCloseTx, bob7.mutualClosePublished.first().tx) + } + + @Test + fun `recv ClosingSigned (other side ignores our fee range, max iterations reached)`() { + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(1_000.sat)) + val (alice2, bob2, aliceCloseSig1) = mutualCloseAlice(alice1, bob) + var mutableAlice = alice2 as ChannelStateWithCommitments + var aliceCloseSig = aliceCloseSig1 + + for (i in 1..Channel.MAX_NEGOTIATION_ITERATIONS) { + val feeRange = aliceCloseSig.tlvStream.get() + assertNotNull(feeRange) + val bobNextFee = (aliceCloseSig.feeSatoshis + 500.sat).max(feeRange.max + 1.sat) + val (_, bobClosing) = makeLegacyClosingSigned(alice2, bob2, bobNextFee) + val (aliceNew, actions) = mutableAlice.processEx(ChannelEvent.MessageReceived(bobClosing)) + aliceCloseSig = actions.findOutgoingMessage() + mutableAlice = aliceNew as ChannelStateWithCommitments + } + + assertTrue(mutableAlice is Closing) + assertEquals(mutableAlice.mutualClosePublished.size, 1) } @Test fun `recv ClosingSigned (invalid signature)`() { val (_, bob, aliceCloseSig) = init() - val (bob1, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig.copy(feeSatoshis = 99000.sat))) + val (bob1, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig.copy(feeSatoshis = 99_000.sat))) assertTrue(bob1 is Closing) actions.hasOutgoingMessage() actions.hasWatch() @@ -150,37 +351,92 @@ class NegotiatingTestsCommon : LightningTestSuite() { assertFalse(bobCloseSig.channelData.isEmpty()) } + @Test + fun `recv BITCOIN_FUNDING_SPENT (counterparty's mutual close)`() { + // NB: we're fundee here, not funder + val (bob, alice) = reachNormal() + val priv = randomKey() + + // Alice initiates a mutual close with a custom final script + val finalScript = Script.write(Script.pay2pkh(priv.publicKey())).toByteVector() + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(finalScript, null))) + val shutdownA = actions1.findOutgoingMessage() + + // Bob replies with Shutdown + ClosingSigned + val (bob1, actions2) = bob.processEx(ChannelEvent.MessageReceived(shutdownA)) + val shutdownB = actions2.findOutgoingMessage() + val closingSignedB = actions2.findOutgoingMessage() + + // Alice agrees with Bob's closing fee, publishes her closing tx and replies with her own ClosingSigned + val (alice2, _) = alice1.processEx(ChannelEvent.MessageReceived(shutdownB)) + val (alice3, actions4) = alice2.processEx(ChannelEvent.MessageReceived(closingSignedB)) + assertTrue(alice3 is Closing) + val closingTxA = actions4.filterIsInstance().first().tx + val closingSignedA = actions4.findOutgoingMessage() + val watch = actions4.findWatch() + assertEquals(watch.txId, closingTxA.txid) + + val fundingTx = Transaction( + version = 2, + txIn = listOf(TxIn(OutPoint(ByteVector32.Zeroes, 0), TxIn.SEQUENCE_FINAL)), + txOut = listOf(bob.commitments.commitInput.txOut), + lockTime = 0 + ) + assertEquals(fundingTx.txid, closingTxA.txIn[0].outPoint.txid) + // check that our closing tx is correctly signed + Transaction.correctlySpends(closingTxA, fundingTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + + // Bob published his closing tx (which should be the same as Alice's) + val (bob2, actions5) = bob1.processEx(ChannelEvent.MessageReceived(closingSignedA)) + assertTrue(bob2 is Closing) + val closingTxB = actions5.filterIsInstance().first().tx + assertEquals(closingTxA, closingTxB) + + // Alice sees Bob's closing tx (which should be the same as the one she published) + val (alice4, _) = alice3.processEx(ChannelEvent.WatchReceived(WatchEventSpent(alice3.channelId, BITCOIN_FUNDING_SPENT, closingTxB))) + assertTrue(alice4 is Closing) + + val (alice5, _) = alice4.processEx(ChannelEvent.WatchReceived(WatchEventConfirmed(alice3.channelId, BITCOIN_TX_CONFIRMED(closingTxA), 144, 0, closingTxA))) + assertTrue(alice5 is Closed) + } + @Test fun `recv BITCOIN_FUNDING_SPENT (an older mutual close)`() { - val (alice, bob, aliceCloseSig) = init() - val (bob1, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) - assertTrue(bob1 is Negotiating) - val bobCloseSig = actions.findOutgoingMessage() - val (alice1, actions1) = alice.processEx(ChannelEvent.MessageReceived(bobCloseSig)) - val aliceCloseSig1 = actions1.findOutgoingMessage() - assertTrue(bobCloseSig.feeSatoshis != aliceCloseSig1.feeSatoshis) - // at this point alice and bob have not yet converged on closing fees, but bob decides to publish a mutual close with one of the previous sigs - val bobClosingTx = Helpers.Closing.checkClosingSignature( - bob1.keyManager, - bob1.commitments, - bob1.localShutdown.scriptPubKey.toByteArray(), - bob1.remoteShutdown.scriptPubKey.toByteArray(), - aliceCloseSig1.feeSatoshis, - aliceCloseSig1.signature - ).right!! - val (alice2, actionsAlice2) = alice1.processEx(ChannelEvent.WatchReceived(WatchEventSpent(alice.channelId, BITCOIN_FUNDING_SPENT, bobClosingTx.tx))) - assertTrue(alice2 is Closing) - actionsAlice2.has() - actionsAlice2.hasTx(bobClosingTx.tx) - assertEquals(actionsAlice2.hasWatch().txId, bobClosingTx.tx.txid) + val (alice, bob) = reachNormal() + val alice1 = alice.updateFeerate(FeeratePerKw(1_000.sat)) + val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) + val (alice2, bob2, aliceCloseSig1) = mutualCloseAlice(alice1, bob1) + + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig1)) + assertTrue(bob3 is Negotiating) + bobActions3.findOutgoingMessage() + val firstMutualCloseTx = bob3.bestUnpublishedClosingTx + assertNotNull(firstMutualCloseTx) + + val (_, bobCloseSig1) = makeLegacyClosingSigned(alice2, bob2, 3_000.sat) + assertNotEquals(bobCloseSig1.feeSatoshis, aliceCloseSig1.feeSatoshis) + val (alice3, aliceActions3) = alice2.processEx(ChannelEvent.MessageReceived(bobCloseSig1)) + assertTrue(alice3 is Negotiating) + val aliceCloseSig2 = aliceActions3.findOutgoingMessage() + assertNotEquals(aliceCloseSig2.feeSatoshis, bobCloseSig1.feeSatoshis) + val latestMutualCloseTx = alice3.bestUnpublishedClosingTx + assertNotNull(latestMutualCloseTx) + assertNotEquals(firstMutualCloseTx.tx.txid, latestMutualCloseTx.tx.txid) + + // at this point bob will receive a new signature, but he decides instead to publish the first mutual close + val (alice4, aliceActions4) = alice3.processEx(ChannelEvent.WatchReceived(WatchEventSpent(alice3.channelId, BITCOIN_FUNDING_SPENT, firstMutualCloseTx.tx))) + assertTrue(alice4 is Closing) + aliceActions4.has() + aliceActions4.hasTx(firstMutualCloseTx.tx) + assertEquals(aliceActions4.hasWatch().txId, firstMutualCloseTx.tx.txid) } @Test fun `recv CMD_CLOSE`() { val (alice, _, _) = init() - val (alice1, actions) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertEquals(alice1, alice) - assertEquals(actions, listOf(ChannelAction.ProcessCmdRes.NotExecuted(CMD_CLOSE(null), ClosingAlreadyInProgress(alice.channelId)))) + assertEquals(actions, listOf(ChannelAction.ProcessCmdRes.NotExecuted(CMD_CLOSE(null, null), ClosingAlreadyInProgress(alice.channelId)))) } @Test @@ -188,14 +444,22 @@ class NegotiatingTestsCommon : LightningTestSuite() { val (alice, _, _) = init() val (alice1, actions) = alice.processEx(ChannelEvent.MessageReceived(Error(ByteVector32.Zeroes, "oops"))) assertTrue(alice1 is Closing) - assertTrue(actions.findTxs().contains(alice.commitments.localCommit.publishableTxs.commitTx.tx)) + actions.hasTx(alice.commitments.localCommit.publishableTxs.commitTx.tx) assertTrue(actions.findWatches().map { it.event }.contains(BITCOIN_TX_CONFIRMED(alice.commitments.localCommit.publishableTxs.commitTx.tx))) } companion object { - fun init(channelType: ChannelType.SupportedChannelType = ChannelType.SupportedChannelType.AnchorOutputs, tweakFees: Boolean = false, pushMsat: MilliSatoshi = TestConstants.pushMsat): Triple { + fun init(channelType: ChannelType.SupportedChannelType = ChannelType.SupportedChannelType.AnchorOutputs, pushMsat: MilliSatoshi = TestConstants.pushMsat): Triple { val (alice, bob) = reachNormal(channelType = channelType, pushMsat = pushMsat) - return mutualClose(alice, bob, tweakFees) + return mutualCloseAlice(alice, bob) + } + + private fun makeLegacyClosingSigned(alice: Negotiating, bob: Negotiating, closingFee: Satoshi): Pair { + val aliceScript = alice.localShutdown.scriptPubKey.toByteArray() + val bobScript = bob.localShutdown.scriptPubKey.toByteArray() + val (_, aliceClosingSigned) = Helpers.Closing.makeClosingTx(alice.keyManager, alice.commitments, aliceScript, bobScript, ClosingFees(closingFee, closingFee, closingFee)) + val (_, bobClosingSigned) = Helpers.Closing.makeClosingTx(bob.keyManager, bob.commitments, bobScript, aliceScript, ClosingFees(closingFee, closingFee, closingFee)) + return Pair(aliceClosingSigned.copy(tlvStream = TlvStream.empty()), bobClosingSigned.copy(tlvStream = TlvStream.empty())) } tailrec fun converge(a: ChannelState, b: ChannelState, aliceCloseSig: ClosingSigned?): Pair? { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt index 2f06fdabc..6b3931d91 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -319,7 +319,7 @@ class NormalTestsCommon : LightningTestSuite() { @Test fun `recv CMD_ADD_HTLC (after having sent Shutdown)`() { val (alice0, _) = reachNormal() - val (alice1, actionsAlice1) = alice0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actionsAlice1) = alice0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) actionsAlice1.findOutgoingMessage() assertTrue(alice1 is Normal && alice1.localShutdown != null && alice1.remoteShutdown == null) @@ -338,7 +338,7 @@ class NormalTestsCommon : LightningTestSuite() { actionsAlice1.findOutgoingMessage() // at the same time bob initiates a closing - val (_, actionsBob1) = bob0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (_, actionsBob1) = bob0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdown = actionsBob1.findOutgoingMessage() val (alice2, _) = alice1.processEx(ChannelEvent.MessageReceived(shutdown)) @@ -1377,7 +1377,7 @@ class NormalTestsCommon : LightningTestSuite() { fun `recv CMD_CLOSE (no pending htlcs)`() { val (alice, _) = reachNormal() assertNull(alice.localShutdown) - val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice1 is Normal) actions1.hasOutgoingMessage() assertNotNull(alice1.localShutdown) @@ -1388,7 +1388,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice, bob) = reachNormal() val (nodes, _, _) = addHtlc(1000.msat, payer = alice, payee = bob) val (alice1, _) = nodes - val (alice2, actions1) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice2, actions1) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice2 is Normal) actions1.hasCommandError() } @@ -1398,7 +1398,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice, bob) = reachNormal() val (nodes, _, _) = addHtlc(1000.msat, payer = alice, payee = bob) val (_, bob1) = nodes - val (bob2, actions1) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob2, actions1) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(bob2 is Normal) actions1.hasOutgoingMessage() assertNotNull(bob2.localShutdown) @@ -1408,7 +1408,7 @@ class NormalTestsCommon : LightningTestSuite() { fun `recv CMD_CLOSE (with invalid final script)`() { val (alice, _) = reachNormal() assertNull(alice.localShutdown) - val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(ByteVector("00112233445566778899")))) + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(ByteVector("00112233445566778899"), null))) assertTrue(alice1 is Normal) actions1.hasCommandError() } @@ -1418,7 +1418,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice, bob) = reachNormal() val (nodes, _, _) = addHtlc(1000.msat, payer = alice, payee = bob) val (alice1, _) = crossSign(nodes.first, nodes.second) - val (alice2, actions1) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice2, actions1) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) actions1.hasOutgoingMessage() assertTrue(alice2 is Normal) assertNotNull(alice2.localShutdown) @@ -1428,11 +1428,11 @@ class NormalTestsCommon : LightningTestSuite() { fun `recv CMD_CLOSE (two in a row)`() { val (alice, _) = reachNormal() assertNull(alice.localShutdown) - val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice1 is Normal) actions1.hasOutgoingMessage() assertNotNull(alice1.localShutdown) - val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice2 is Normal) actions2.hasCommandError() } @@ -1444,7 +1444,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice1, actions1) = nodes.first.processEx(ChannelEvent.ExecuteCommand(CMD_SIGN)) assertTrue(alice1 is Normal) actions1.hasOutgoingMessage() - val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice2 is Normal) actions2.hasOutgoingMessage() } @@ -1454,10 +1454,10 @@ class NormalTestsCommon : LightningTestSuite() { val (alice, _) = reachNormal() val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_UPDATE_FEE(FeeratePerKw(20_000.sat), false))) actions1.hasOutgoingMessage() - val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice2, actions2) = alice1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) actions2.hasCommandError() val (alice3, _) = alice2.processEx(ChannelEvent.ExecuteCommand(CMD_SIGN)) - val (alice4, actions4) = alice3.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice4, actions4) = alice3.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice4 is Normal) actions4.hasOutgoingMessage() } @@ -1475,7 +1475,7 @@ class NormalTestsCommon : LightningTestSuite() { fun `recv Shutdown (with unacked sent htlcs)`() { val (alice, bob) = reachNormal() val (nodes, _, _) = addHtlc(50000000.msat, payer = alice, payee = bob) - val (bob1, actions1) = nodes.second.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob1, actions1) = nodes.second.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdown = actions1.findOutgoingMessage() val (alice1, actions2) = nodes.first.processEx(ChannelEvent.MessageReceived(shutdown)) @@ -1515,7 +1515,7 @@ class NormalTestsCommon : LightningTestSuite() { // Bob initiates a close before receiving the signature. val (bob1, _) = bob.processEx(ChannelEvent.MessageReceived(updateFee)) - val (bob2, bobActions2) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob2, bobActions2) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdownBob = bobActions2.hasOutgoingMessage() val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(sigAlice)) @@ -1556,7 +1556,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice, bob) = reachNormal() val (nodes, _, _) = addHtlc(50000000.msat, payer = alice, payee = bob) val (_, bob1) = crossSign(nodes.first, nodes.second) - val (bob2, actions1) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob2, actions1) = bob1.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) actions1.hasOutgoingMessage() // actual test begins @@ -1585,7 +1585,7 @@ class NormalTestsCommon : LightningTestSuite() { val (nodes, _, _) = addHtlc(50000000.msat, payer = alice, payee = bob) val (alice1, actions1) = nodes.first.processEx(ChannelEvent.ExecuteCommand(CMD_SIGN)) actions1.hasOutgoingMessage() - val (_, actions2) = bob.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (_, actions2) = bob.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdown = actions2.findOutgoingMessage() // actual test begins @@ -1598,7 +1598,7 @@ class NormalTestsCommon : LightningTestSuite() { fun `recv Shutdown (while waiting for a RevokeAndAck with pending outgoing htlc)`() { val (alice, bob) = reachNormal() // let's make bob send a Shutdown message - val (bob1, actions1) = bob.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob1, actions1) = bob.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdown = actions1.findOutgoingMessage() // this is just so we have something to sign diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt index db3850ebe..b26354431 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt @@ -361,7 +361,7 @@ class ShutdownTestsCommon : LightningTestSuite() { val (_, bob0) = reachNormal() assertTrue(bob0.commitments.localParams.features.hasFeature(Feature.ChannelBackupClient)) assertFalse(bob0.commitments.channelFeatures.hasFeature(Feature.ChannelBackupClient)) // this isn't a permanent channel feature - val (bob1, actions1) = bob0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob1, actions1) = bob0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(bob1 is Normal) val blob = Serialization.encrypt(bob1.staticParams.nodeParams.nodePrivateKey.value, bob1) val shutdown = actions1.findOutgoingMessage() @@ -479,9 +479,9 @@ class ShutdownTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (alice, _) = init() - val (alice1, actions) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertEquals(alice1, alice) - assertEquals(actions, listOf(ChannelAction.ProcessCmdRes.NotExecuted(CMD_CLOSE(null), ClosingAlreadyInProgress(alice.channelId)))) + assertEquals(actions, listOf(ChannelAction.ProcessCmdRes.NotExecuted(CMD_CLOSE(null, null), ClosingAlreadyInProgress(alice.channelId)))) } private fun testLocalForceClose(alice: ChannelState, actions: List) { @@ -566,7 +566,7 @@ class ShutdownTestsCommon : LightningTestSuite() { fun shutdown(alice: ChannelState, bob: ChannelState): Pair { // Alice initiates a closing - val (alice1, actionsAlice) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actionsAlice) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) val shutdown = actionsAlice.findOutgoingMessage() val (bob1, actionsBob) = bob.processEx(ChannelEvent.MessageReceived(shutdown)) val shutdown1 = actionsBob.findOutgoingMessage() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt index 9a9fad18d..157a00d23 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt @@ -179,7 +179,7 @@ class WaitForAcceptChannelTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (alice, _, _) = init() - val (alice1, actions1) = alice.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions1) = alice.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice1 is Aborted) assertTrue(actions1.isEmpty()) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt index 09e26f1a7..c3cb98dd9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingConfirmedTestsCommon.kt @@ -146,7 +146,7 @@ class WaitForFundingConfirmedTestsCommon : LightningTestSuite() { fun `recv CMD_CLOSE`() { val (alice, bob) = init(ChannelType.SupportedChannelType.AnchorOutputs, TestConstants.fundingAmount, TestConstants.pushMsat) listOf(alice, bob).forEach { state -> - val (state1, actions1) = state.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (state1, actions1) = state.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertEquals(state, state1) actions1.hasCommandError() } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt index 113c9ddb2..de269aa08 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt @@ -62,7 +62,7 @@ class WaitForFundingCreatedTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (_, bob, _) = init(ChannelType.SupportedChannelType.AnchorOutputs, TestConstants.fundingAmount, TestConstants.pushMsat) - val (bob1, actions1) = bob.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob1, actions1) = bob.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue { bob1 is Aborted } assertTrue { actions1.isEmpty() } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingLockedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingLockedTestsCommon.kt index 4796993a9..f3c16aa4e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingLockedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingLockedTestsCommon.kt @@ -80,7 +80,7 @@ class WaitForFundingLockedTestsCommon : LightningTestSuite() { fun `recv CMD_CLOSE`() { val (alice, bob, _) = init() listOf(alice, bob).forEach { state -> - val (state1, actions1) = state.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (state1, actions1) = state.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertEquals(state, state1) assertEquals(1, actions1.size) actions1.hasCommandError() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt index 308d4867c..5fc9963de 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt @@ -62,7 +62,7 @@ class WaitForFundingSignedTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (alice, _, _) = init() - val (alice1, actions1) = alice.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions1) = alice.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice1 is Aborted) assertNull(actions1.findOutgoingMessageOpt()) } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt index 7704b992b..b6e41ac28 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt @@ -242,7 +242,7 @@ class WaitForOpenChannelTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (_, bob, _) = TestsHelper.init() - val (bob1, actions) = bob.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (bob1, actions) = bob.process(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue { bob1 is Aborted } assertTrue { actions.isEmpty() } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 4f8334134..02cd0a0a9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -586,6 +586,54 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode closing_signed`() { + val defaultSig = ByteVector64("01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101") + val testCases = listOf( + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 0000000000000000 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") to ClosingSigned( + ByteVector32.One, + 0.sat, + ByteVector64.Zeroes + ), + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 00000000000003e8 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") to ClosingSigned( + ByteVector32.One, + 1000.sat, + ByteVector64.Zeroes + ), + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 00000000000005dc 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101") to ClosingSigned( + ByteVector32.One, + 1500.sat, + defaultSig + ), + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 00000000000005dc 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 0110000000000000006400000000000007d0") to ClosingSigned( + ByteVector32.One, + 1500.sat, + ByteVector64.Zeroes, + TlvStream(listOf(ClosingSignedTlv.FeeRange(100.sat, 2000.sat))) + ), + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 00000000000003e8 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 0110000000000000006400000000000007d0") to ClosingSigned( + ByteVector32.One, + 1000.sat, + defaultSig, + TlvStream(listOf(ClosingSignedTlv.FeeRange(100.sat, 2000.sat))) + ), + Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 0000000000000064 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 0110000000000000006400000000000003e8 030401020304") to ClosingSigned( + ByteVector32.One, + 100.sat, + ByteVector64.Zeroes, + TlvStream(listOf(ClosingSignedTlv.FeeRange(100.sat, 1000.sat)), listOf(GenericTlv(3, ByteVector("01020304")))) + ), + ) + + testCases.forEach { + val decoded = LightningMessage.decode(it.first) + assertNotNull(decoded) + assertEquals(decoded, it.second) + val reEncoded = LightningMessage.encode(decoded) + assertArrayEquals(reEncoded, it.first) + } + } + @Test fun `nonreg backup channel data`() { val channelId = randomBytes32() @@ -632,11 +680,11 @@ class LightningCodecsTestsCommon : LightningTestSuite() { Hex.decode("0026") + channelId.toByteArray() + Hex.decode("002a") + randomData + Hex.decode("01 02 0102") + Hex.decode("fe47010000 07 cccccccccccccc") to Shutdown(channelId, randomData.toByteVector(), TlvStream(listOf(ShutdownTlv.ChannelData(EncryptedChannelData(ByteVector("cccccccccccccc")))), listOf(GenericTlv(1, ByteVector("0102"))))), Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() to ClosingSigned(channelId, 123456789.sat, signature), - Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("01 02 0102") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(), listOf(GenericTlv(1, ByteVector("0102"))))), + Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("03 02 0102") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(), listOf(GenericTlv(3, ByteVector("0102"))))), Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("fe47010000 00") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(ClosingSignedTlv.ChannelData(EncryptedChannelData.empty)))), - Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("01 02 0102") + Hex.decode("fe47010000 00") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(ClosingSignedTlv.ChannelData(EncryptedChannelData.empty)), listOf(GenericTlv(1, ByteVector("0102"))))), + Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("03 02 0102") + Hex.decode("fe47010000 00") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(ClosingSignedTlv.ChannelData(EncryptedChannelData.empty)), listOf(GenericTlv(3, ByteVector("0102"))))), Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingSigned(channelId, 123456789.sat, signature).withChannelData(ByteVector("cccccccccccccc")), - Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("01 02 0102") + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(ClosingSignedTlv.ChannelData(EncryptedChannelData(ByteVector("cccccccccccccc")))), listOf(GenericTlv(1, ByteVector("0102"))))) + Hex.decode("0027") + channelId.toByteArray() + Hex.decode("00000000075bcd15") + signature.toByteArray() + Hex.decode("03 02 0102") + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(listOf(ClosingSignedTlv.ChannelData(EncryptedChannelData(ByteVector("cccccccccccccc")))), listOf(GenericTlv(3, ByteVector("0102"))))) ) // @formatter:on diff --git a/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt b/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt index 7c76c9a19..ba9e255c8 100644 --- a/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt +++ b/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt @@ -265,7 +265,7 @@ object Node { } post("/channels/{channelId}/close") { val channelId = ByteVector32(call.parameters["channelId"] ?: error("channelId not provided")) - peer.send(WrappedChannelEvent(channelId, ChannelEvent.ExecuteCommand(CMD_CLOSE(null)))) + peer.send(WrappedChannelEvent(channelId, ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null)))) call.respond(CloseChannelResponse("pending")) } }