diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt index b2c76ea86..a3491274d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Channel.kt @@ -999,7 +999,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) @@ -1758,6 +1758,7 @@ data class WaitForFundingLocked( initialChannelUpdate, null, null, + null, null ) val actions = listOf( @@ -1810,7 +1811,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) @@ -1873,7 +1875,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) } @@ -1940,7 +1942,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) { @@ -1949,13 +1951,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) @@ -2021,7 +2023,7 @@ data class Normal( commitments1, localShutdown.scriptPubKey.toByteArray(), event.message.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate, + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate), ) val nextState = Negotiating( staticParams, @@ -2031,19 +2033,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) } @@ -2121,7 +2124,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) @@ -2160,7 +2164,7 @@ data class ShuttingDown( commitments1, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) ) val nextState = Negotiating( staticParams, @@ -2170,7 +2174,8 @@ data class ShuttingDown( localShutdown, remoteShutdown, listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null + bestUnpublishedClosingTx = null, + closingFeerates ) val actions = listOf( ChannelAction.Storage.StoreState(nextState), @@ -2180,7 +2185,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) } @@ -2208,7 +2213,7 @@ data class ShuttingDown( commitments1, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), - currentOnChainFeerates.mutualCloseFeerate + closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) ) val nextState = Negotiating( staticParams, @@ -2218,13 +2223,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) } @@ -2334,7 +2340,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" } @@ -2346,82 +2353,107 @@ 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 fees=$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 -> { + logger.info { "c:$channelId they chose a fee in our range, 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 fees=$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 fees=${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 fees=$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 fees=${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) { @@ -2470,6 +2502,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/ChannelTypes.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelTypes.kt index d68343bed..714e3ce3d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelTypes.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelTypes.kt @@ -11,6 +11,7 @@ import fr.acinq.lightning.channel.Helpers.watchConfirmedIfNeeded import fr.acinq.lightning.channel.Helpers.watchSpentIfNeeded import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* +import fr.acinq.lightning.transactions.Transactions.weight2fee import fr.acinq.lightning.utils.BitField import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.ClosingSigned @@ -49,8 +50,17 @@ 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 865b8f0b9..e54080d87 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -403,14 +403,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 @@ -420,10 +420,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( @@ -431,14 +431,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) } @@ -449,11 +449,11 @@ 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)) val signedClosingTx = Transactions.addSigs(closingTx, commitments.localParams.channelKeys.fundingPubKey, commitments.remoteParams.fundingPubKey, closingSigned.signature, remoteClosingSig) return 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)) } } 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 60f8c7fbd..be361ce07 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v1/ChannelState.kt @@ -705,7 +705,8 @@ data class Normal( channelUpdate, remoteChannelUpdate, localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -733,7 +734,8 @@ data class ShuttingDown( currentOnChainFeerates.export(), commitments.export(nodeParams), localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -772,7 +774,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 0673dd1ff..f4b1d8c4b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -698,7 +698,8 @@ data class Normal( channelUpdate, remoteChannelUpdate, localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -726,7 +727,8 @@ data class ShuttingDown( currentOnChainFeerates.export(), commitments.export(nodeParams), localShutdown, - remoteShutdown + remoteShutdown, + null ) } @@ -765,7 +767,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/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 5bd7188fb..e64dc405a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -7,6 +7,7 @@ import fr.acinq.bitcoin.io.Input import fr.acinq.bitcoin.io.Output import fr.acinq.lightning.channel.ChannelOrigin import fr.acinq.lightning.channel.ChannelVersion +import fr.acinq.lightning.serialization.v2.SatoshiKSerializer import fr.acinq.lightning.utils.BitField import fr.acinq.lightning.utils.toByteVector import kotlinx.serialization.Contextual @@ -173,6 +174,21 @@ sealed class ShutdownTlv : Tlv { @Serializable sealed class ClosingSignedTlv : Tlv { + @Serializable + data class FeeRange(@Serializable(with = SatoshiKSerializer::class) val min: Satoshi, @Serializable(with = SatoshiKSerializer::class) 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 050c39d1b..8440662db 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -1044,7 +1044,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 b66ab70e6..4ce9e13dd 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -173,32 +173,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 6fe403a01..26d79a36c 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -18,7 +18,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 @@ -32,23 +33,6 @@ import fr.acinq.lightning.wire.* 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`() { @@ -78,43 +62,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 @@ -124,11 +104,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 @@ -138,23 +118,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 @@ -1588,7 +1561,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) @@ -1761,7 +1734,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 976159555..3e77585e9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt @@ -4,9 +4,11 @@ import fr.acinq.bitcoin.* 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 @@ -14,25 +16,344 @@ 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 !! + 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() + } + + 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) + + // 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) + + // 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) + + // 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)) + } + + @Test + fun `recv ClosingSigned (theirCloseFee != ourCloseFee)`() { + val (alice, bob) = reachNormal() + testClosingSignedDifferentFees(alice, bob) + } + + @Test + fun `recv ClosingSigned (theirCloseFee != ourCloseFee, bob starts closing)`() { + val (alice, bob) = reachNormal() + testClosingSignedDifferentFees(alice, bob, bobInitiates = true) + } + + @Test + 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 (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() + 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) = 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.process(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 `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.process(ChannelEvent.MessageReceived(bobCloseSig)) + assertTrue(alice3 is Closing) + aliceActions3.hasTx(mutualCloseTx) + } + + @Test + fun `recv ClosingSigned (nothing at stake)`() { + 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 (_, 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 = 99_000.sat))) + assertTrue(bob1 is Closing) + actions.hasOutgoingMessage() + actions.hasWatch() + actions.findTxs().contains(bob.commitments.localCommit.publishableTxs.commitTx.tx) + } + + @Test + fun `recv ClosingSigned with encrypted channel data`() { + val (_, _, aliceCloseSig) = init(ChannelVersion.STANDARD or ChannelVersion.ZERO_RESERVE) + assertFalse(aliceCloseSig.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))) + val (alice1, actions1) = alice.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(finalScript, null))) val shutdownA = actions1.findOutgoingMessage() // Bob replies with Shutdown + ClosingSigned @@ -59,7 +380,7 @@ class NegotiatingTestsCommon : LightningTestSuite() { // 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 !!!) + // 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 @@ -74,107 +395,42 @@ class NegotiatingTestsCommon : LightningTestSuite() { } @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() - } - - @Test - fun `recv ClosingSigned (theirCloseFee != ourCloseFee)`() { - val (alice, bob, aliceCloseSig) = init() - val (_, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig)) - // Bob answers with a counter proposition - 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)) - } + fun `recv BITCOIN_FUNDING_SPENT (an older mutual close)`() { + 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) - @Test - fun `recv ClosingSigned (theirCloseFee == ourCloseFee)`() { - val (alice, bob, aliceCloseSig) = init() - assertTrue { converge(alice, bob, aliceCloseSig) != null } - } + val (bob3, bobActions3) = bob2.processEx(ChannelEvent.MessageReceived(aliceCloseSig1)) + assertTrue(bob3 is Negotiating) + bobActions3.findOutgoingMessage() + val firstMutualCloseTx = bob3.bestUnpublishedClosingTx + assertNotNull(firstMutualCloseTx) - @Test - fun `recv ClosingSigned (theirCloseFee == ourCloseFee, different fee parameters)`() { - val (alice, bob, aliceCloseSig) = init(tweakFees = true) - assertTrue { converge(alice, bob, aliceCloseSig) != null } - } + 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) - @Test - fun `recv ClosingSigned (nothing at stake)`() { - val (alice, bob, aliceCloseSig) = init(pushMsat = 0.msat) - // 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)) - } - - @Test - fun `recv ClosingSigned (invalid signature)`() { - val (_, bob, aliceCloseSig) = init() - val (bob1, actions) = bob.processEx(ChannelEvent.MessageReceived(aliceCloseSig.copy(feeSatoshis = 99000.sat))) - assertTrue(bob1 is Closing) - actions.hasOutgoingMessage() - actions.hasWatch() - actions.findTxs().contains(bob.commitments.localCommit.publishableTxs.commitTx.tx) - } - - @Test - fun `recv ClosingSigned with encrypted channel data`() { - val (_, _, aliceCloseSig) = init(ChannelVersion.STANDARD or ChannelVersion.ZERO_RESERVE) - assertFalse(aliceCloseSig.channelData.isEmpty()) - } - - @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) + // 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 @@ -182,14 +438,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(channelVersion: ChannelVersion = ChannelVersion.STANDARD, tweakFees: Boolean = false, pushMsat: MilliSatoshi = TestConstants.pushMsat): Triple { + fun init(channelVersion: ChannelVersion = ChannelVersion.STANDARD, pushMsat: MilliSatoshi = TestConstants.pushMsat): Triple { val (alice, bob) = reachNormal(channelVersion = channelVersion, 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 ed9726662..30f4adbe9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -318,7 +318,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) @@ -337,7 +337,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)) @@ -1369,7 +1369,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) @@ -1380,7 +1380,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() } @@ -1390,7 +1390,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) @@ -1400,7 +1400,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() } @@ -1410,7 +1410,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) @@ -1420,11 +1420,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() } @@ -1436,7 +1436,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() } @@ -1446,10 +1446,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() } @@ -1467,7 +1467,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)) @@ -1507,7 +1507,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)) @@ -1548,7 +1548,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 @@ -1577,7 +1577,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 @@ -1590,7 +1590,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 38992ff60..db4aa682f 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt @@ -354,7 +354,7 @@ class ShutdownTestsCommon : LightningTestSuite() { @Test fun `recv Shutdown with encrypted channel data`() { val (alice0, _) = reachNormal(ChannelVersion.STANDARD or ChannelVersion.ZERO_RESERVE) - val (alice1, actions1) = alice0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null))) + val (alice1, actions1) = alice0.processEx(ChannelEvent.ExecuteCommand(CMD_CLOSE(null, null))) assertTrue(alice1 is Normal) val blob = Serialization.encrypt(alice1.staticParams.nodeParams.nodePrivateKey.value, alice1) val shutdown = actions1.findOutgoingMessage() @@ -472,9 +472,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) { @@ -554,7 +554,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 467f02ae0..34bc6f530 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForAcceptChannelTestsCommon.kt @@ -127,7 +127,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 5f1d8d954..408befd50 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(ChannelVersion.STANDARD, 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 0868023a6..e293b96a7 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingCreatedTestsCommon.kt @@ -61,7 +61,7 @@ class WaitForFundingCreatedTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (_, bob, _) = init(ChannelVersion.STANDARD, 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 1ee3b1667..a9aead7c8 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 b2726ed6e..590e1753e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForFundingSignedTestsCommon.kt @@ -50,7 +50,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 9a868c232..ddef71ac6 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/WaitForOpenChannelTestsCommon.kt @@ -162,7 +162,7 @@ class WaitForOpenChannelTestsCommon : LightningTestSuite() { @Test fun `recv CMD_CLOSE`() { val (_, bob, _) = TestsHelper.init(ChannelVersion.STANDARD, TestConstants.defaultBlockHeight, TestConstants.fundingAmount) - 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 851b809ae..96b2fdaa0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -552,6 +552,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() @@ -598,11 +646,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 2434617c0..cc85a0788 100644 --- a/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt +++ b/src/jvmTest/kotlin/fr/acinq/lightning/Node.kt @@ -264,7 +264,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")) } }