From 79f3e0e75f3f366cd750e7671d3418b76e8ead48 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 9 Feb 2024 15:44:18 +0100 Subject: [PATCH] Implement the option_simple_close protocol We update the `Negotiating` state to exchange the `closing_complete` and `closing_sig` messages, and allow RBF-ing previous transactions and updating our closing script. We stay in that state until one of the transactions confirms, or a force close is detected. This is important to ensure we're able to correctly reconnect and negotiate RBF candidates. --- .../kotlin/fr/acinq/lightning/NodeParams.kt | 3 + .../acinq/lightning/channel/ChannelCommand.kt | 3 +- .../fr/acinq/lightning/channel/ChannelData.kt | 7 +- .../lightning/channel/ChannelException.kt | 2 + .../fr/acinq/lightning/channel/Helpers.kt | 182 +++-- .../acinq/lightning/channel/states/Channel.kt | 82 +-- .../acinq/lightning/channel/states/Closing.kt | 15 - .../lightning/channel/states/Negotiating.kt | 311 ++++---- .../acinq/lightning/channel/states/Normal.kt | 73 +- .../acinq/lightning/channel/states/Offline.kt | 34 +- .../lightning/channel/states/ShuttingDown.kt | 129 ++-- .../acinq/lightning/channel/states/Syncing.kt | 65 +- .../acinq/lightning/json/JsonSerializers.kt | 33 +- .../serialization/v2/ChannelState.kt | 37 +- .../serialization/v2/Serialization.kt | 1 - .../serialization/v3/ChannelState.kt | 60 +- .../serialization/v3/Serialization.kt | 1 - .../serialization/v4/Deserialization.kt | 97 ++- .../serialization/v4/Serialization.kt | 32 +- .../lightning/transactions/Transactions.kt | 68 +- .../fr/acinq/lightning/wire/ChannelTlv.kt | 26 - .../acinq/lightning/wire/LightningMessages.kt | 39 - .../fr/acinq/lightning/channel/TestsHelper.kt | 49 +- .../channel/states/ClosingTestsCommon.kt | 186 +---- .../channel/states/NegotiatingTestsCommon.kt | 675 ++++++++---------- .../channel/states/NormalTestsCommon.kt | 40 +- .../channel/states/QuiescenceTestsCommon.kt | 3 +- .../channel/states/ShutdownTestsCommon.kt | 21 +- .../OutgoingPaymentHandlerTestsCommon.kt | 7 +- .../fr/acinq/lightning/tests/TestConstants.kt | 2 + .../transactions/TransactionsTestsCommon.kt | 77 +- .../wire/LightningCodecsTestsCommon.kt | 64 +- 32 files changed, 1134 insertions(+), 1290 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 54e1e58c4..4aa8bfebc 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -174,6 +174,8 @@ data class NodeParams( require(features.hasFeature(Feature.PaymentSecret, FeatureSupport.Mandatory)) { "${Feature.PaymentSecret.rfcName} should be mandatory" } require(features.hasFeature(Feature.ChannelType, FeatureSupport.Mandatory)) { "${Feature.ChannelType.rfcName} should be mandatory" } require(features.hasFeature(Feature.DualFunding, FeatureSupport.Mandatory)) { "${Feature.DualFunding.rfcName} should be mandatory" } + require(features.hasFeature(Feature.ShutdownAnySegwit, FeatureSupport.Mandatory)) { "${Feature.ShutdownAnySegwit.rfcName} should be mandatory" } + require(features.hasFeature(Feature.SimpleClose, FeatureSupport.Mandatory)) { "${Feature.SimpleClose.rfcName} should be mandatory" } require(!features.hasFeature(Feature.ZeroConfChannels)) { "${Feature.ZeroConfChannels.rfcName} has been deprecated: use the zeroConfPeers whitelist instead" } require(!features.hasFeature(Feature.TrustedSwapInClient)) { "${Feature.TrustedSwapInClient.rfcName} has been deprecated" } require(!features.hasFeature(Feature.TrustedSwapInProvider)) { "${Feature.TrustedSwapInProvider.rfcName} has been deprecated" } @@ -199,6 +201,7 @@ data class NodeParams( Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, + Feature.SimpleClose to FeatureSupport.Mandatory, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.ZeroReserveChannels to FeatureSupport.Optional, Feature.WakeUpNotificationClient to FeatureSupport.Optional, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt index 0a3f7643c..5c5834c10 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelCommand.kt @@ -6,7 +6,6 @@ import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.blockchain.WatchEvent import fr.acinq.lightning.blockchain.electrum.WalletState import fr.acinq.lightning.blockchain.fee.FeeratePerKw -import fr.acinq.lightning.channel.states.ClosingFeerates import fr.acinq.lightning.channel.states.PersistedChannelState import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.utils.UUID @@ -132,7 +131,7 @@ sealed class ChannelCommand { } sealed class Close : ChannelCommand() { - data class MutualClose(val scriptPubKey: ByteVector?, val feerates: ClosingFeerates?) : Close(), ForbiddenDuringSplice, ForbiddenDuringQuiescence + data class MutualClose(val scriptPubKey: ByteVector?, val feerate: FeeratePerKw?) : Close(), ForbiddenDuringSplice, ForbiddenDuringQuiescence data object ForceClose : Close() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt index 248ba9eab..66fa7e6db 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt @@ -9,10 +9,9 @@ import fr.acinq.lightning.channel.Helpers.publishIfNeeded import fr.acinq.lightning.channel.Helpers.watchConfirmedIfNeeded import fr.acinq.lightning.channel.Helpers.watchSpentIfNeeded import fr.acinq.lightning.crypto.KeyManager -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.LoggingContext import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* -import fr.acinq.lightning.wire.ClosingSigned /** * Details about a force-close where we published our commitment. @@ -354,7 +353,7 @@ data class LocalParams( val defaultFinalScriptPubKey: ByteVector, val features: Features ) { - constructor(nodeParams: NodeParams, isInitiator: Boolean): this( + constructor(nodeParams: NodeParams, isInitiator: Boolean) : this( nodeId = nodeParams.nodeId, fundingKeyPath = nodeParams.keyManager.newFundingKeyPath(isInitiator), // we make sure that initiator and non-initiator key path end differently dustLimit = nodeParams.dustLimit, @@ -389,8 +388,6 @@ object ChannelFlags { const val Empty = 0x00.toByte() } -data class ClosingTxProposed(val unsignedTx: ClosingTx, val localClosingSigned: ClosingSigned) - /** Reason for creating a new channel or a splice. */ // @formatter:off sealed class Origin { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt index 3f08d7cc2..09c8c4428 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelException.kt @@ -60,6 +60,8 @@ data class FeerateTooSmall (override val channelId: Byte data class FeerateTooDifferent (override val channelId: ByteVector32, val localFeeratePerKw: FeeratePerKw, val remoteFeeratePerKw: FeeratePerKw) : ChannelException(channelId, "local/remote feerates are too different: remoteFeeratePerKw=${remoteFeeratePerKw.toLong()} localFeeratePerKw=${localFeeratePerKw.toLong()}") data class InvalidCommitmentSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid commitment signature: txId=$txId") data class InvalidHtlcSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid htlc signature: txId=$txId") +data class CannotGenerateClosingTx (override val channelId: ByteVector32) : ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed") +data class MissingCloseSignature (override val channelId: ByteVector32) : ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output") data class InvalidCloseSignature (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid close signature: txId=$txId") data class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, val txId: TxId) : ChannelException(channelId, "invalid closing tx: some outputs are below dust: txId=$txId") data class CommitSigCountMismatch (override val channelId: ByteVector32, val expected: Int, val actual: Int) : ChannelException(channelId, "commit sig count mismatch: expected=$expected actual=$actual") diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt index 6b739076d..8d8b3ba1d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt @@ -5,8 +5,6 @@ import fr.acinq.bitcoin.Crypto.ripemd160 import fr.acinq.bitcoin.Crypto.sha256 import fr.acinq.bitcoin.Script.pay2wsh import fr.acinq.bitcoin.Script.write -import fr.acinq.lightning.Feature -import fr.acinq.lightning.Features import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.BITCOIN_OUTPUT_SPENT @@ -18,13 +16,11 @@ import fr.acinq.lightning.blockchain.fee.FeerateTolerance import fr.acinq.lightning.blockchain.fee.OnChainFeerates import fr.acinq.lightning.channel.Helpers.Closing.inputsAlreadySpent import fr.acinq.lightning.channel.states.Channel -import fr.acinq.lightning.channel.states.ClosingFeerates -import fr.acinq.lightning.channel.states.ClosingFees import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForCommitment import fr.acinq.lightning.crypto.Bolt3Derivation.deriveForRevocation import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.crypto.ShaChain -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.LoggingContext import fr.acinq.lightning.transactions.* import fr.acinq.lightning.transactions.Scripts.multiSig2of2 import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClaimHtlcDelayedOutputPenaltyTx @@ -278,7 +274,14 @@ object Helpers { ) } - data class PairOfCommitTxs(val localSpec: CommitmentSpec, val localCommitTx: Transactions.TransactionWithInputInfo.CommitTx, val localHtlcTxs: List, val remoteSpec: CommitmentSpec, val remoteCommitTx: Transactions.TransactionWithInputInfo.CommitTx, val remoteHtlcTxs: List) + data class PairOfCommitTxs( + val localSpec: CommitmentSpec, + val localCommitTx: Transactions.TransactionWithInputInfo.CommitTx, + val localHtlcTxs: List, + val remoteSpec: CommitmentSpec, + val remoteCommitTx: Transactions.TransactionWithInputInfo.CommitTx, + val remoteHtlcTxs: List + ) /** * Creates both sides' first commitment transaction. @@ -304,7 +307,7 @@ object Helpers { remotePerCommitmentPoint: PublicKey ): Either { val localSpec = CommitmentSpec(localHtlcs, commitTxFeerate, toLocal = toLocal, toRemote = toRemote) - val remoteSpec = CommitmentSpec(localHtlcs.map{ it.opposite() }.toSet(), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) + val remoteSpec = CommitmentSpec(localHtlcs.map { it.opposite() }.toSet(), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) if (!localParams.isInitiator) { // They initiated the channel open, therefore they pay the fee: we need to make sure they can afford it! @@ -371,74 +374,131 @@ object Helpers { fun isValidFinalScriptPubkey(scriptPubKey: ByteVector, allowAnySegwit: Boolean, allowOpReturn: Boolean): Boolean = isValidFinalScriptPubkey(scriptPubKey.toByteArray(), allowAnySegwit, allowOpReturn) - private fun firstClosingFee(commitment: FullCommitment, 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(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.isInitiator, Satoshi(0), Satoshi(0), commitment.localCommit.spec) - val closingWeight = Transaction.weight(Transactions.addSigs(dummyClosingTx, dummyPublicKey, commitment.remoteFundingPubkey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig).tx) - return requestedFeerate.computeFees(closingWeight) + /** + * Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk + * that the closing transaction will not be relayed to miners' mempool and will not confirm. + * The various dust limits are detailed in https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits + */ + fun checkClosingDustAmounts(closingTx: ClosingTx): Boolean { + return closingTx.tx.txOut.all { txOut -> txOut.amount >= Transactions.dustLimit(txOut.publicKeyScript) } } - fun firstClosingFee(commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, requestedFeerate: ClosingFeerates): ClosingFees = - firstClosingFee(commitment, localScriptPubkey.toByteArray(), remoteScriptPubkey.toByteArray(), requestedFeerate) - - fun nextClosingFee(localClosingFee: Satoshi, remoteClosingFee: Satoshi): Satoshi = ((localClosingFee + remoteClosingFee) / 4) * 2 - - fun makeFirstClosingTx( + /** We are the closer: we sign closing transactions for which we pay the fees. */ + fun makeClosingTxs( channelKeys: KeyManager.ChannelKeys, commitment: FullCommitment, - localScriptPubkey: ByteArray, - remoteScriptPubkey: ByteArray, - requestedFeerate: ClosingFeerates - ): Pair { - val closingFees = firstClosingFee(commitment, localScriptPubkey, remoteScriptPubkey, requestedFeerate) - return makeClosingTx(channelKeys, commitment, localScriptPubkey, remoteScriptPubkey, closingFees) + localScriptPubkey: ByteVector, + remoteScriptPubkey: ByteVector, + feerate: FeeratePerKw, + ): Either> { + require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true)) { "invalid localScriptPubkey" } + require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true)) { "invalid remoteScriptPubkey" } + // We want to signal replaceability and use a widely used value for nSequence to avoid fingerprinting. + val sequence = 0xFFFFFFFDL + val lockTime = 0L + val closingFee = run { + val dummyClosingTxs = Transactions.makeClosingTxs(commitment.commitInput, commitment.localCommit.spec, Transactions.ClosingTxFee.PaidByUs(0.sat), sequence, lockTime, localScriptPubkey, remoteScriptPubkey) + when (val dummyTx = dummyClosingTxs.preferred) { + null -> return Either.Left(CannotGenerateClosingTx(commitment.channelId)) + else -> { + val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig) + Transactions.ClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight())) + } + } + } + val closingTxs = Transactions.makeClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, sequence, lockTime, localScriptPubkey, remoteScriptPubkey) + // The actual fee we're paying will be bigger than the one we previously computed if we omit our output. + val actualFee = closingTxs.preferred?.fee ?: 0.sat + if (actualFee == 0.sat) { + return Either.Left(CannotGenerateClosingTx(commitment.channelId)) + } + val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val tlvs = TlvStream( + setOfNotNull( + closingTxs.localAndRemote?.let { tx -> ClosingCompleteTlv.CloserAndClosee(Transactions.sign(tx, localFundingKey)) }, + closingTxs.localOnly?.let { tx -> ClosingCompleteTlv.CloserNoClosee(Transactions.sign(tx, localFundingKey)) }, + closingTxs.remoteOnly?.let { tx -> ClosingCompleteTlv.NoCloserClosee(Transactions.sign(tx, localFundingKey)) }, + ) + ) + val closingComplete = ClosingComplete(commitment.channelId, actualFee, sequence.toUInt(), lockTime, tlvs) + return Either.Right(Pair(closingTxs, closingComplete)) } - fun makeClosingTx( - channelKeys: KeyManager.ChannelKeys, - commitment: FullCommitment, - localScriptPubkey: ByteArray, - remoteScriptPubkey: ByteArray, - closingFees: ClosingFees - ): Pair { - val allowAnySegwit = Features.canUseFeature(commitment.params.localParams.features, commitment.params.remoteParams.features, Feature.ShutdownAnySegwit) - val allowOpReturn = Features.canUseFeature(commitment.params.localParams.features, commitment.params.remoteParams.features, Feature.SimpleClose) - require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit, allowOpReturn)) { "invalid localScriptPubkey" } - require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit, allowOpReturn)) { "invalid remoteScriptPubkey" } - val dustLimit = commitment.params.localParams.dustLimit.max(commitment.params.remoteParams.dustLimit) - val closingTx = Transactions.makeClosingTx(commitment.commitInput, localScriptPubkey, remoteScriptPubkey, commitment.params.localParams.isInitiator, dustLimit, closingFees.preferred, commitment.localCommit.spec) - val localClosingSig = Transactions.sign(closingTx, channelKeys.fundingKey(commitment.fundingTxIndex)) - val closingSigned = ClosingSigned(commitment.channelId, closingFees.preferred, localClosingSig, TlvStream(ClosingSignedTlv.FeeRange(closingFees.min, closingFees.max))) - return Pair(closingTx, closingSigned) - } - - fun checkClosingSignature( + /** + * We are the closee: we choose one of the closer's transactions and sign it back. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_complete doesn't match the latest state of the closing negotiation (someone changed their script). + */ + fun signClosingTx( channelKeys: KeyManager.ChannelKeys, commitment: FullCommitment, - localScriptPubkey: ByteArray, - remoteScriptPubkey: ByteArray, - remoteClosingFee: Satoshi, - remoteClosingSig: ByteVector64 - ): Either> { - val (closingTx, closingSigned) = makeClosingTx(channelKeys, commitment, localScriptPubkey, remoteScriptPubkey, ClosingFees(remoteClosingFee)) - return if (checkClosingDustAmounts(closingTx)) { - val signedClosingTx = Transactions.addSigs(closingTx, channelKeys.fundingPubKey(commitment.fundingTxIndex), commitment.remoteFundingPubkey, closingSigned.signature, remoteClosingSig) - when (Transactions.checkSpendable(signedClosingTx)) { - is Try.Success -> Either.Right(Pair(signedClosingTx, closingSigned)) - is Try.Failure -> Either.Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + localScriptPubkey: ByteVector, + remoteScriptPubkey: ByteVector, + closingComplete: ClosingComplete + ): Either> { + val closingFee = Transactions.ClosingTxFee.PaidByThem(closingComplete.fees) + val closingTxs = Transactions.makeClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.sequence.toLong(), closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey) + // If our output isn't dust, they must provide a signature for a transaction that includes it. + // Note that we're the closee, so we look for signatures including the closee output. + if (closingTxs.localAndRemote != null && closingTxs.localOnly != null && closingComplete.closerAndCloseeSig == null && closingComplete.noCloserCloseeSig == null) { + return Either.Left(MissingCloseSignature(commitment.channelId)) + } + if (closingTxs.localAndRemote != null && closingTxs.localOnly == null && closingComplete.closerAndCloseeSig == null) { + return Either.Left(MissingCloseSignature(commitment.channelId)) + } + if (closingTxs.localAndRemote == null && closingTxs.localOnly != null && closingComplete.noCloserCloseeSig == null) { + return Either.Left(MissingCloseSignature(commitment.channelId)) + } + // We choose the closing signature that matches our preferred closing transaction. + val closingTxsWithSigs = listOfNotNull ClosingSigTlv>>( + closingComplete.closerAndCloseeSig?.let { remoteSig -> closingTxs.localAndRemote?.let { tx -> Triple(tx, remoteSig) { localSig: ByteVector64 -> ClosingSigTlv.CloserAndClosee(localSig) } } }, + closingComplete.noCloserCloseeSig?.let { remoteSig -> closingTxs.localOnly?.let { tx -> Triple(tx, remoteSig) { localSig -> ClosingSigTlv.NoCloserClosee(localSig) } } }, + closingComplete.closerNoCloseeSig?.let { remoteSig -> closingTxs.remoteOnly?.let { tx -> Triple(tx, remoteSig) { localSig -> ClosingSigTlv.CloserNoClosee(localSig) } } }, + ) + return when (val preferred = closingTxsWithSigs.firstOrNull()) { + null -> Either.Left(MissingCloseSignature(commitment.channelId)) + else -> { + val (closingTx, remoteSig, sigToTlv) = preferred + val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val localSig = Transactions.sign(closingTx, localFundingKey) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingKey.publicKey(), commitment.remoteFundingPubkey, localSig, remoteSig) + when (Transactions.checkSpendable(signedClosingTx)) { + is Try.Failure -> Either.Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + is Try.Success -> Either.Right(Pair(signedClosingTx, ClosingSig(commitment.channelId, TlvStream(sigToTlv(localSig))))) + } } - } else { - Either.Left(InvalidCloseAmountBelowDust(commitment.channelId, closingTx.tx.txid)) } } /** - * Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk - * that the closing transaction will not be relayed to miners' mempool and will not confirm. - * The various dust limits are detailed in https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits + * We are the closer: they sent us their signature so we should now have a fully signed closing transaction. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_sig doesn't match the latest state of the closing negotiation (someone changed their script). */ - fun checkClosingDustAmounts(closingTx: ClosingTx): Boolean { - return closingTx.tx.txOut.all { txOut -> txOut.amount >= Transactions.dustLimit(txOut.publicKeyScript) } + fun receiveClosingSig( + channelKeys: KeyManager.ChannelKeys, + commitment: FullCommitment, + closingTxs: Transactions.ClosingTxs, + closingSig: ClosingSig + ): Either { + val closingTxsWithSig = listOfNotNull( + closingSig.closerAndCloseeSig?.let { sig -> closingTxs.localAndRemote?.let { tx -> Pair(tx, sig) } }, + closingSig.closerNoCloseeSig?.let { sig -> closingTxs.localOnly?.let { tx -> Pair(tx, sig) } }, + closingSig.noCloserCloseeSig?.let { sig -> closingTxs.remoteOnly?.let { tx -> Pair(tx, sig) } }, + ) + return when (val preferred = closingTxsWithSig.firstOrNull()) { + null -> Either.Left(MissingCloseSignature(commitment.channelId)) + else -> { + val (closingTx, remoteSig) = preferred + val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val localSig = Transactions.sign(closingTx, localFundingKey) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingKey.publicKey(), commitment.remoteFundingPubkey, localSig, remoteSig) + when (Transactions.checkSpendable(signedClosingTx)) { + is Try.Failure -> Either.Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + is Try.Success -> Either.Right(signedClosingTx) + } + } + } } /** diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt index d6063f507..569a72aa6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt @@ -15,10 +15,14 @@ import fr.acinq.lightning.channel.Helpers.Closing.claimRevokedRemoteCommitTxOutp import fr.acinq.lightning.channel.Helpers.Closing.getRemotePerCommitmentSecret import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.db.ChannelClosingType -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.LoggingContext +import fr.acinq.lightning.logging.MDCLogger import fr.acinq.lightning.serialization.Encryption.from import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.Either +import fr.acinq.lightning.utils.map +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat import fr.acinq.lightning.wire.* /* @@ -68,7 +72,8 @@ sealed class ChannelState { it is ChannelAction.Message.Send && it.message is CommitSig -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) it is ChannelAction.Message.Send && it.message is RevokeAndAck -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) it is ChannelAction.Message.Send && it.message is Shutdown -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) - it is ChannelAction.Message.Send && it.message is ClosingSigned -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) + it is ChannelAction.Message.Send && it.message is ClosingComplete -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) + it is ChannelAction.Message.Send && it.message is ClosingSig -> it.copy(message = it.message.withChannelData(EncryptedChannelData.from(privateKey, this@ChannelState), logger)) else -> it } } @@ -105,7 +110,7 @@ sealed class ChannelState { } } - private fun ChannelContext.emitClosingEvents(oldState: ChannelStateWithCommitments, newState: Closing): List { + internal fun ChannelContext.emitClosingEvents(oldState: ChannelStateWithCommitments, newState: Closing): List { val channelBalance = oldState.commitments.latest.localCommit.spec.toLocal return if (channelBalance > 0.msat) { when { @@ -214,20 +219,15 @@ sealed class ChannelState { is Normal -> forceClose(state) is ShuttingDown -> forceClose(state) is Negotiating -> when { - state.bestUnpublishedClosingTx != null -> { - // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that + state.publishedClosingTxs.isNotEmpty() -> { + // If we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that. val nextState = Closing( state.commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = state.closingTxProposed.flatten().map { it.unsignedTx } + listOf(state.bestUnpublishedClosingTx), - mutualClosePublished = listOf(state.bestUnpublishedClosingTx) - ) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Blockchain.PublishTx(state.bestUnpublishedClosingTx), - ChannelAction.Blockchain.SendWatch(WatchConfirmed(state.channelId, state.bestUnpublishedClosingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(state.bestUnpublishedClosingTx.tx))) + waitingSinceBlock = state.waitingSinceBlock, + mutualCloseProposed = state.proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = state.publishedClosingTxs ) - Pair(nextState, actions) + Pair(nextState, listOf(ChannelAction.Storage.StoreState(nextState))) } else -> forceClose(state) } @@ -257,20 +257,18 @@ sealed class ChannelState { logger.error { "peer sent error: ascii='${e.toAscii()}' bin=${e.data.toHex()}" } return when (this@ChannelState) { is Closing -> Pair(this@ChannelState, listOf()) // nothing to do, there is already a spending tx published - is Negotiating -> when (this@ChannelState.bestUnpublishedClosingTx) { - null -> this.spendLocalCurrent() - else -> { + is Negotiating -> when { + publishedClosingTxs.isNotEmpty() -> { + // If we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that. val nexState = Closing( commitments = commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOfNotNull(bestUnpublishedClosingTx) + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = publishedClosingTxs ) - Pair(nexState, buildList { - add(ChannelAction.Storage.StoreState(nexState)) - addAll(doPublish(bestUnpublishedClosingTx, nexState.channelId)) - }) + Pair(nexState, listOf(ChannelAction.Storage.StoreState(nexState))) } + else -> this.spendLocalCurrent() } is WaitForFundingSigned -> Pair(Aborted, listOf(ChannelAction.Storage.RemoveChannel(this@ChannelState))) // NB: we publish the commitment even if we have nothing at stake (in a dataloss situation our peer will send us an error just for that) @@ -423,8 +421,9 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { is Closing -> this@ChannelStateWithCommitments.copy(remoteCommitPublished = remoteCommitPublished) is Negotiating -> Closing( commitments = commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = publishedClosingTxs, remoteCommitPublished = remoteCommitPublished ) else -> Closing( @@ -452,8 +451,9 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { is Closing -> copy(nextRemoteCommitPublished = remoteCommitPublished) is Negotiating -> Closing( commitments = commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = publishedClosingTxs, nextRemoteCommitPublished = remoteCommitPublished ) else -> Closing( @@ -484,8 +484,9 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { } is Negotiating -> Closing( commitments = commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = publishedClosingTxs, revokedCommitPublished = listOf(revokedCommitPublished) ) else -> Closing( @@ -537,7 +538,7 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { val remoteCommitPublished = claimRemoteCommitMainOutput(channelKeys(), commitments.params, tx, currentOnChainFeerates.claimMainFeerate) val nextState = when (this@ChannelStateWithCommitments) { is Closing -> this@ChannelStateWithCommitments.copy(remoteCommitPublished = remoteCommitPublished) - is Negotiating -> Closing(commitments, waitingSinceBlock = currentBlockHeight.toLong(), mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, remoteCommitPublished = remoteCommitPublished) + is Negotiating -> Closing(commitments, waitingSinceBlock, proposedClosingTxs.flatMap { it.all }, publishedClosingTxs, remoteCommitPublished = remoteCommitPublished) else -> Closing(commitments, waitingSinceBlock = currentBlockHeight.toLong(), remoteCommitPublished = remoteCommitPublished) } return Pair(nextState, buildList { @@ -573,8 +574,9 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { is Closing -> copy(localCommitPublished = localCommitPublished) is Negotiating -> Closing( commitments = commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = publishedClosingTxs, localCommitPublished = localCommitPublished ) else -> Closing( @@ -610,18 +612,6 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { logger.error { channelEx.message } when { this@ChannelStateWithCommitments is Closing -> Pair(this@ChannelStateWithCommitments, listOf()) // nothing to do, there is already a spending tx published - this@ChannelStateWithCommitments is Negotiating && this@ChannelStateWithCommitments.bestUnpublishedClosingTx != null -> { - val nexState = Closing( - commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOfNotNull(bestUnpublishedClosingTx) - ) - Pair(nexState, buildList { - add(ChannelAction.Storage.StoreState(nexState)) - addAll(doPublish(bestUnpublishedClosingTx, nexState.channelId)) - }) - } else -> { val error = Error(channelId, channelEx.message) spendLocalCurrent().run { copy(second = second + ChannelAction.Message.Send(error)) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt index 9ee59eb1f..3bb237d26 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt @@ -1,10 +1,8 @@ package fr.acinq.lightning.channel.states -import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.Transaction import fr.acinq.bitcoin.updated import fr.acinq.lightning.blockchain.* -import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.Helpers.Closing.claimCurrentLocalCommitTxOutputs import fr.acinq.lightning.channel.Helpers.Closing.claimRemoteCommitTxOutputs @@ -14,25 +12,12 @@ import fr.acinq.lightning.channel.Helpers.Closing.extractPreimages import fr.acinq.lightning.channel.Helpers.Closing.onChainOutgoingHtlcs import fr.acinq.lightning.channel.Helpers.Closing.overriddenOutgoingHtlcs import fr.acinq.lightning.channel.Helpers.Closing.timedOutHtlcs -import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx import fr.acinq.lightning.utils.Either import fr.acinq.lightning.utils.getValue import fr.acinq.lightning.wire.ChannelReestablish import fr.acinq.lightning.wire.Error -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) { - fun computeFees(closingTxWeight: Int): ClosingFees = ClosingFees(Transactions.weight2fee(preferred, closingTxWeight), Transactions.weight2fee(min, closingTxWeight), Transactions.weight2fee(max, closingTxWeight)) - - companion object { - operator fun invoke(preferred: FeeratePerKw): ClosingFeerates = ClosingFeerates(preferred, preferred / 2, preferred * 2) - } -} - sealed class ClosingType data class MutualClose(val tx: ClosingTx) : ClosingType() data class LocalClose(val localCommit: LocalCommit, val localCommitPublished: LocalCommitPublished) : ClosingType() diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt index b3b51f4dd..11de2c535 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Negotiating.kt @@ -1,15 +1,14 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.Transaction -import fr.acinq.bitcoin.updated import fr.acinq.lightning.blockchain.* +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* -import fr.acinq.lightning.channel.states.Channel.MAX_NEGOTIATION_ITERATIONS +import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx import fr.acinq.lightning.utils.Either -import fr.acinq.lightning.utils.msat -import fr.acinq.lightning.wire.ClosingSigned -import fr.acinq.lightning.wire.ClosingSignedTlv +import fr.acinq.lightning.wire.ClosingComplete +import fr.acinq.lightning.wire.ClosingSig import fr.acinq.lightning.wire.Error import fr.acinq.lightning.wire.Shutdown @@ -17,147 +16,136 @@ data class Negotiating( override val commitments: Commitments, 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 closingFeerates: ClosingFeerates? + // Closing transactions we created, where we pay the fees (unsigned). + val proposedClosingTxs: List, + // Closing transactions we published: this contains our local transactions for + // which they sent a signature, and their closing transactions that we signed. + val publishedClosingTxs: List, + val closingFeerate: FeeratePerKw?, + val waitingSinceBlock: Long, // how many blocks since we initiated the closing ) : ChannelStateWithCommitments() { - init { - require(closingTxProposed.isNotEmpty()) { "there must always be a list for the current negotiation" } - require(!commitments.params.localParams.isInitiator || !closingTxProposed.any { it.isEmpty() }) { "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing" } - } - override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) override fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { return when (cmd) { is ChannelCommand.MessageReceived -> when (cmd.message) { - is ClosingSigned -> { - val remoteClosingFee = cmd.message.feeSatoshis - logger.info { "received closing fee=$remoteClosingFee" } - when (val result = - Helpers.Closing.checkClosingSignature(channelKeys(), commitments.latest, localShutdown.scriptPubKey.toByteArray(), remoteShutdown.scriptPubKey.toByteArray(), cmd.message.feeSatoshis, cmd.message.signature)) { - is Either.Left -> handleLocalError(cmd, result.value) + is ClosingComplete -> { + val result = Helpers.Closing.signClosingTx( + channelKeys(), + commitments.latest, + localShutdown.scriptPubKey, + remoteShutdown.scriptPubKey, + cmd.message + ) + when (result) { + is Either.Left -> { + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_complete`. + logger.warning { "invalid closing_complete: ${result.value.message}" } + Pair(this@Negotiating, listOf()) + } + is Either.Right -> { + val (closingTx, closingSig) = result.value + val nextState = this@Negotiating.copy(publishedClosingTxs = publishedClosingTxs + closingTx) + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Blockchain.PublishTx(closingTx), + ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, closingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(closingTx.tx))), + ChannelAction.Message.Send(closingSig) + ) + Pair(nextState, actions) + } + } + } + is ClosingSig -> { + val result = Helpers.Closing.receiveClosingSig( + channelKeys(), + commitments.latest, + proposedClosingTxs.last(), + cmd.message + ) + when (result) { + is Either.Left -> { + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_sig`. + logger.warning { "invalid closing_sig: ${result.value.message}" } + Pair(this@Negotiating, listOf()) + } is Either.Right -> { - val (signedClosingTx, closingSignedRemoteFees) = result.value - val lastLocalClosingSigned = closingTxProposed.last().lastOrNull()?.localClosingSigned - when { - lastLocalClosingSigned?.feeSatoshis == remoteClosingFee -> { - logger.info { "they accepted our fee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } - completeMutualClose(signedClosingTx, null) - } - closingTxProposed.flatten().size >= MAX_NEGOTIATION_ITERATIONS -> { - logger.warning { "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 { "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.latest.localCommit.spec.toLocal == 0.msat -> { - logger.info { "we have nothing at stake, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } - completeMutualClose(signedClosingTx, closingSignedRemoteFees) - } - else -> { - val theirFeeRange = cmd.message.tlvStream.get() - val ourFeeRange = closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) - when { - theirFeeRange != null && !commitments.params.localParams.isInitiator -> { - // if we are not the initiator 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.latest, 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 { "accepting their closing fee=$remoteClosingFee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } - completeMutualClose(signedClosingTx, closingSignedRemoteFees) - } else { - val (closingTx, closingSigned) = Helpers.Closing.makeClosingTx( - channelKeys(), - commitments.latest, - localShutdown.scriptPubKey.toByteArray(), - remoteShutdown.scriptPubKey.toByteArray(), - ClosingFees(closingFee, theirFeeRange.min, theirFeeRange.max) - ) - logger.info { "proposing closing fee=${closingSigned.feeSatoshis}" } - val closingProposed1 = closingTxProposed.updated( - closingTxProposed.lastIndex, - closingTxProposed.last() + listOf(ClosingTxProposed(closingTx, closingSigned)) - ) - val nextState = this@Negotiating.copy( - commitments = commitments.copy(remoteChannelData = cmd.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 not the initiator 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.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, ourFeeRange) - val nextPreferredFee = Helpers.Closing.nextClosingFee(lastLocalClosingSigned?.feeSatoshis ?: localClosingFees.preferred, remoteClosingFee) - Helpers.Closing.makeClosingTx( - channelKeys(), - commitments.latest, - 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 { "accepting their closing fee=$remoteClosingFee, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } - completeMutualClose(signedClosingTx, null) - } - closingSigned.feeSatoshis == remoteClosingFee -> { - logger.info { "we have converged, publishing closing tx: closingTxId=${signedClosingTx.tx.txid}" } - completeMutualClose(signedClosingTx, closingSigned) - } - else -> { - logger.info { "proposing closing fee=${closingSigned.feeSatoshis}" } - val closingProposed1 = closingTxProposed.updated( - closingTxProposed.lastIndex, - closingTxProposed.last() + listOf(ClosingTxProposed(closingTx, closingSigned)) - ) - val nextState = this@Negotiating.copy( - commitments = commitments.copy(remoteChannelData = cmd.message.channelData), - closingTxProposed = closingProposed1, - bestUnpublishedClosingTx = signedClosingTx - ) - val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned)) - Pair(nextState, actions) - } - } - } - } - } + val closingTx = result.value + val nextState = this@Negotiating.copy(publishedClosingTxs = publishedClosingTxs + closingTx) + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Blockchain.PublishTx(closingTx), + ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, closingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(closingTx.tx))), + ) + Pair(nextState, actions) + } + } + } + is Shutdown -> { + if (cmd.message.scriptPubKey != remoteShutdown.scriptPubKey) { + // Our peer changed their closing script: we sign a new version of our closing transaction using the new script. + logger.info { "our peer updated their shutdown script (previous=${remoteShutdown.scriptPubKey}, current=${cmd.message.scriptPubKey})" } + val result = Helpers.Closing.makeClosingTxs( + channelKeys(), + commitments.latest, + localShutdown.scriptPubKey, + cmd.message.scriptPubKey, + closingFeerate ?: currentOnChainFeerates.mutualCloseFeerate, + ) + when (result) { + is Either.Left -> { + logger.warning { "cannot create local closing txs for new remote script, waiting for remote closing_complete: ${result.value.message}" } + val nextState = this@Negotiating.copy(remoteShutdown = cmd.message) + Pair(nextState, listOf(ChannelAction.Storage.StoreState(nextState))) + } + is Either.Right -> { + val (closingTxs, closingComplete) = result.value + val nextState = this@Negotiating.copy(remoteShutdown = cmd.message, proposedClosingTxs = proposedClosingTxs + closingTxs) + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Message.Send(closingComplete) + ) + Pair(nextState, actions) } } + } else { + // This is a retransmission of their previous shutdown, we can ignore it. + Pair(this@Negotiating, listOf()) } } is Error -> handleRemoteError(cmd.message) else -> unhandled(cmd) } is ChannelCommand.WatchReceived -> when (val watch = cmd.watch) { - is WatchEventConfirmed -> updateFundingTxStatus(watch) + is WatchEventConfirmed -> when { + publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // One of our published transactions confirmed, the channel is now closed. + completeMutualClose(publishedClosingTxs.first { it.tx.txid == watch.tx.txid }) + } + proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // A transaction that we proposed for which they didn't send us their signature was confirmed, the channel is now closed. + completeMutualClose(getMutualClosePublished(watch.tx)) + } + else -> { + // Otherwise, this must be a funding transaction that just got confirmed. + updateFundingTxStatus(watch) + } + } is WatchEventSpent -> when { - watch.event is BITCOIN_FUNDING_SPENT && closingTxProposed.flatten().any { it.unsignedTx.tx.txid == watch.tx.txid } -> { - // they can publish a closing tx with any sig we sent them, even if we are not done negotiating - logger.info { "closing tx published: closingTxId=${watch.tx.txid}" } + watch.event is BITCOIN_FUNDING_SPENT && publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // This is one of the transactions we already published, we have nothing to do. + Pair(this@Negotiating, listOf()) + } + watch.event is BITCOIN_FUNDING_SPENT && proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // They published one of our closing transactions without sending us their signature. val closingTx = getMutualClosePublished(watch.tx) - val nextState = Closing( - commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOf(closingTx) - ) + val nextState = this@Negotiating.copy(publishedClosingTxs = publishedClosingTxs + closingTx) val actions = listOf( ChannelAction.Storage.StoreState(nextState), ChannelAction.Blockchain.PublishTx(closingTx), @@ -173,7 +161,42 @@ data class Negotiating( is ChannelCommand.Htlc.Add -> handleCommandError(cmd, ChannelUnavailable(channelId)) is ChannelCommand.Htlc -> unhandled(cmd) is ChannelCommand.Close.ForceClose -> handleLocalError(cmd, ForcedLocalCommit(channelId)) - is ChannelCommand.Close.MutualClose -> handleCommandError(cmd, ClosingAlreadyInProgress(channelId)) + is ChannelCommand.Close.MutualClose -> { + if (cmd.scriptPubKey != null || cmd.feerate != null) { + // We're updating our script or feerate (or both), so we generate a new set of closing transactions. + val localShutdown1 = cmd.scriptPubKey?.let { localShutdown.copy(scriptPubKey = it) } ?: localShutdown + val result = Helpers.Closing.makeClosingTxs( + channelKeys(), + commitments.latest, + localShutdown1.scriptPubKey, + remoteShutdown.scriptPubKey, + cmd.feerate ?: closingFeerate ?: currentOnChainFeerates.mutualCloseFeerate, + ) + when (result) { + is Either.Left -> { + logger.warning { "cannot create local closing txs, waiting for remote closing_complete: ${result.value.message}" } + val nextState = this@Negotiating.copy(localShutdown = localShutdown1, closingFeerate = cmd.feerate ?: closingFeerate) + val actions = buildList { + add(ChannelAction.Storage.StoreState(nextState)) + if (cmd.scriptPubKey != null) add(ChannelAction.Message.Send(localShutdown1)) + } + Pair(nextState, actions) + } + is Either.Right -> { + val (closingTxs, closingComplete) = result.value + val nextState = this@Negotiating.copy(localShutdown = localShutdown1, closingFeerate = cmd.feerate ?: closingFeerate, proposedClosingTxs = proposedClosingTxs + closingTxs) + val actions = buildList { + add(ChannelAction.Storage.StoreState(nextState)) + if (cmd.scriptPubKey != null) add(ChannelAction.Message.Send(localShutdown1)) + add(ChannelAction.Message.Send(closingComplete)) + } + Pair(nextState, actions) + } + } + } else { + handleCommandError(cmd, ClosingAlreadyInProgress(channelId)) + } + } is ChannelCommand.Init -> unhandled(cmd) is ChannelCommand.Funding -> unhandled(cmd) is ChannelCommand.Closing -> unhandled(cmd) @@ -182,25 +205,27 @@ data class Negotiating( } } - /** Return full information about a known closing tx. */ + /** Return full information about a closing tx that we proposed and they then published. */ internal fun getMutualClosePublished(tx: Transaction): ClosingTx { - // they can publish a closing tx with any sig we sent them, even if we are not done negotiating - // they added their signature, so we use their version of the transaction - return closingTxProposed.flatten().first { it.unsignedTx.tx.txid == tx.txid }.unsignedTx.copy(tx = tx) + // They can publish a closing tx with any sig we sent them, even if we are not done negotiating. + // They added their signature, so we use their version of the transaction. + return proposedClosingTxs.flatMap { it.all }.first { it.tx.txid == tx.txid }.copy(tx = tx) } - private fun ChannelContext.completeMutualClose(signedClosingTx: ClosingTx, closingSigned: ClosingSigned?): Pair> { - val nextState = Closing( - commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOf(signedClosingTx) + internal fun ChannelContext.completeMutualClose(signedClosingTx: ClosingTx): Pair> { + logger.info { "channel was closed with txId=${signedClosingTx.tx.txid}" } + val nextState = Closed( + Closing( + commitments, + waitingSinceBlock = waitingSinceBlock, + mutualCloseProposed = proposedClosingTxs.flatMap { it.all }, + mutualClosePublished = listOf(signedClosingTx) + ) ) val actions = buildList { add(ChannelAction.Storage.StoreState(nextState)) - closingSigned?.let { add(ChannelAction.Message.Send(it)) } - add(ChannelAction.Blockchain.PublishTx(signedClosingTx)) - add(ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, signedClosingTx.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(signedClosingTx.tx)))) + addAll(emitClosingEvents(this@Negotiating, nextState.state)) + add(ChannelAction.Storage.SetLocked(signedClosingTx.tx.txid)) } return Pair(nextState, actions) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt index b0db8537b..86642c274 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Normal.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.blockchain.WatchEventConfirmed import fr.acinq.lightning.blockchain.WatchEventSpent +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions @@ -23,7 +24,7 @@ data class Normal( val remoteChannelUpdate: ChannelUpdate?, val localShutdown: Shutdown?, val remoteShutdown: Shutdown?, - val closingFeerates: ClosingFeerates?, + val closingFeerate: FeeratePerKw?, val spliceStatus: SpliceStatus, val liquidityLeases: List, ) : ChannelStateWithCommitments() { @@ -100,7 +101,7 @@ data class Normal( !Helpers.Closing.isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit, allowOpReturn) -> handleCommandError(cmd, InvalidFinalScript(channelId), channelUpdate) else -> { val shutdown = Shutdown(channelId, localScriptPubkey) - val newState = this@Normal.copy(localShutdown = shutdown, closingFeerates = cmd.feerates) + val newState = this@Normal.copy(localShutdown = shutdown, closingFeerate = cmd.feerate) val actions = listOf(ChannelAction.Storage.StoreState(newState), ChannelAction.Message.Send(shutdown)) Pair(newState, actions) } @@ -239,22 +240,28 @@ data class Normal( actions.add(ChannelAction.Message.Send(localShutdown)) if (commitments1.latest.remoteCommit.spec.htlcs.isNotEmpty()) { // we just signed htlcs that need to be resolved now - ShuttingDown(commitments1, localShutdown, remoteShutdown, closingFeerates) + ShuttingDown(commitments1, localShutdown, remoteShutdown, closingFeerate) } else { logger.warning { "we have no htlcs but have not replied with our Shutdown yet, this should never happen" } - val closingTxProposed = if (isInitiator) { - val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( - channelKeys(), - commitments1.latest, - localShutdown.scriptPubKey.toByteArray(), - remoteShutdown.scriptPubKey.toByteArray(), - closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate), - ) - listOf(listOf(ClosingTxProposed(closingTx, closingSigned))) - } else { - listOf(listOf()) + val closingResult = Helpers.Closing.makeClosingTxs( + channelKeys(), + commitments1.latest, + localShutdown.scriptPubKey, + remoteShutdown.scriptPubKey, + closingFeerate ?: currentOnChainFeerates.mutualCloseFeerate + ) + val proposedClosingTxs = when (closingResult) { + is Either.Left -> { + logger.warning { "cannot create local closing txs, waiting for remote closing_complete: ${closingResult.value.message}" } + listOf() + } + is Either.Right -> { + val (closingTxs, closingComplete) = closingResult.value + actions.add(ChannelAction.Message.Send(closingComplete)) + listOf(closingTxs) + } } - Negotiating(commitments1, localShutdown, remoteShutdown, closingTxProposed, bestUnpublishedClosingTx = null, closingFeerates) + Negotiating(commitments1, localShutdown, remoteShutdown, proposedClosingTxs, listOf(), closingFeerate, currentBlockHeight.toLong()) } } else { this@Normal.copy(commitments = commitments1) @@ -314,33 +321,31 @@ data class Normal( if (this@Normal.localShutdown == null) actions.add(ChannelAction.Message.Send(localShutdown)) val commitments1 = commitments.copy(remoteChannelData = cmd.message.channelData) when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { - val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( + commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { + val closingResult = Helpers.Closing.makeClosingTxs( channelKeys(), commitments1.latest, - localShutdown.scriptPubKey.toByteArray(), - cmd.message.scriptPubKey.toByteArray(), - closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate), - ) - val nextState = Negotiating( - commitments1, - localShutdown, - cmd.message, - listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null, - closingFeerates + localShutdown.scriptPubKey, + cmd.message.scriptPubKey, + closingFeerate ?: currentOnChainFeerates.mutualCloseFeerate ) - actions.addAll(listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned))) - Pair(nextState, actions) - } - commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(commitments1, localShutdown, cmd.message, listOf(listOf()), null, closingFeerates) + val nextState = when (closingResult) { + is Either.Left -> { + logger.warning { "cannot create local closing txs, waiting for remote closing_complete: ${closingResult.value.message}" } + Negotiating(commitments1, localShutdown, cmd.message, listOf(), listOf(), closingFeerate, currentBlockHeight.toLong()) + } + is Either.Right -> { + val (closingTxs, closingComplete) = closingResult.value + actions.add(ChannelAction.Message.Send(closingComplete)) + Negotiating(commitments1, localShutdown, cmd.message, listOf(closingTxs), listOf(), closingFeerate, currentBlockHeight.toLong()) + } + } 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(commitments1, localShutdown, cmd.message, closingFeerates) + val nextState = ShuttingDown(commitments1, localShutdown, cmd.message, closingFeerate) actions.add(ChannelAction.Storage.StoreState(nextState)) Pair(nextState, actions) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Offline.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Offline.kt index 99b1456f3..15a616129 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Offline.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Offline.kt @@ -56,8 +56,8 @@ data class Offline(val state: PersistedChannelState) : ChannelState() { is ChannelCommand.MessageReceived -> unhandled(cmd) is ChannelCommand.WatchReceived -> when (state) { is ChannelStateWithCommitments -> when (val watch = cmd.watch) { - is WatchEventConfirmed -> { - if (watch.event is BITCOIN_FUNDING_DEPTHOK) { + is WatchEventConfirmed -> when { + watch.event is BITCOIN_FUNDING_DEPTHOK -> { when (val res = state.run { acceptFundingTxConfirmed(watch) }) { is Either.Left -> Pair(this@Offline, listOf()) is Either.Right -> { @@ -75,26 +75,32 @@ data class Offline(val state: PersistedChannelState) : ChannelState() { Pair(this@Offline.copy(state = nextState), actions + listOf(ChannelAction.Storage.StoreState(nextState))) } } - } else { - Pair(this@Offline, listOf()) } + state is Negotiating && state.publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // One of our published transactions confirmed, the channel is now closed. + state.run { completeMutualClose(publishedClosingTxs.first { it.tx.txid == watch.tx.txid }) } + } + state is Negotiating && state.proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // A transaction that we proposed for which they didn't send us their signature was confirmed, the channel is now closed. + state.run { completeMutualClose(getMutualClosePublished(watch.tx)) } + } + else -> Pair(this@Offline, listOf()) } is WatchEventSpent -> when { - state is Negotiating && state.closingTxProposed.flatten().any { it.unsignedTx.tx.txid == watch.tx.txid } -> { - logger.info { "closing tx published: closingTxId=${watch.tx.txid}" } - val closingTx = state.getMutualClosePublished(watch.tx) - val nextState = Closing( - state.commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = state.closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOf(closingTx) - ) + state is Negotiating && state.publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // This is one of the transactions we already published, we have nothing to do. + Pair(this@Offline, listOf()) + } + state is Negotiating && state.proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // They published one of our closing transactions without sending us their signature. + val closingTx = state.run { getMutualClosePublished(watch.tx) } + val nextState = state.copy(publishedClosingTxs = state.publishedClosingTxs + closingTx) val actions = listOf( ChannelAction.Storage.StoreState(nextState), ChannelAction.Blockchain.PublishTx(closingTx), ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, watch.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(watch.tx))) ) - Pair(nextState, actions) + Pair(Offline(nextState), actions) } else -> { val (nextState, actions) = state.run { handlePotentialForceClose(watch) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt index 40e0ae7a4..1567c12d2 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/ShuttingDown.kt @@ -2,6 +2,7 @@ package fr.acinq.lightning.channel.states import fr.acinq.lightning.blockchain.WatchEventConfirmed import fr.acinq.lightning.blockchain.WatchEventSpent +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.transactions.Transactions import fr.acinq.lightning.utils.Either @@ -11,10 +12,12 @@ data class ShuttingDown( override val commitments: Commitments, val localShutdown: Shutdown, val remoteShutdown: Shutdown, - val closingFeerates: ClosingFeerates? + val closingFeerate: FeeratePerKw? ) : ChannelStateWithCommitments() { override fun updateCommitments(input: Commitments): ChannelStateWithCommitments = this.copy(commitments = input) + private fun ChannelContext.closingFeerate(): FeeratePerKw = closingFeerate ?: currentOnChainFeerates.mutualCloseFeerate + override fun ChannelContext.processInternal(cmd: ChannelCommand): Pair> { return when (cmd) { is ChannelCommand.MessageReceived -> { @@ -45,33 +48,35 @@ data class ShuttingDown( is Either.Right -> { val (commitments1, revocation) = result.value when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { - val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( + commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { + val closingResult = Helpers.Closing.makeClosingTxs( channelKeys(), commitments1.latest, - localShutdown.scriptPubKey.toByteArray(), - remoteShutdown.scriptPubKey.toByteArray(), - closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) - ) - val nextState = Negotiating( - commitments1, - localShutdown, - remoteShutdown, - listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null, - closingFeerates - ) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Message.Send(revocation), - ChannelAction.Message.Send(closingSigned) + localShutdown.scriptPubKey, + remoteShutdown.scriptPubKey, + closingFeerate() ) - Pair(nextState, actions) - } - commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(listOf()), null, closingFeerates) - val actions = listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(revocation)) - Pair(nextState, actions) + when (closingResult) { + is Either.Left -> { + logger.warning { "cannot create local closing txs, waiting for remote closing_complete: ${closingResult.value.message}" } + val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(), listOf(), closingFeerate, currentBlockHeight.toLong()) + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Message.Send(revocation), + ) + Pair(nextState, actions) + } + is Either.Right -> { + val (closingTxs, closingComplete) = closingResult.value + val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(closingTxs), listOf(), closingFeerate, currentBlockHeight.toLong()) + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Message.Send(revocation), + ChannelAction.Message.Send(closingComplete) + ) + Pair(nextState, actions) + } + } } else -> { val nextState = this@ShuttingDown.copy(commitments = commitments1) @@ -93,29 +98,28 @@ data class ShuttingDown( val (commitments1, actions) = result.value val actions1 = actions.toMutableList() when { - commitments1.hasNoPendingHtlcsOrFeeUpdate() && commitments1.params.localParams.isInitiator -> { - val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( + commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { + val closingResult = Helpers.Closing.makeClosingTxs( channelKeys(), commitments1.latest, - localShutdown.scriptPubKey.toByteArray(), - remoteShutdown.scriptPubKey.toByteArray(), - closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) - ) - val nextState = Negotiating( - commitments1, - localShutdown, - remoteShutdown, - listOf(listOf(ClosingTxProposed(closingTx, closingSigned))), - bestUnpublishedClosingTx = null, - closingFeerates + localShutdown.scriptPubKey, + remoteShutdown.scriptPubKey, + closingFeerate() ) - actions1.addAll(listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingSigned))) - Pair(nextState, actions1) - } - commitments1.hasNoPendingHtlcsOrFeeUpdate() -> { - val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(listOf()), null, closingFeerates) - actions1.add(ChannelAction.Storage.StoreState(nextState)) - Pair(nextState, actions1) + when (closingResult) { + is Either.Left -> { + logger.warning { "cannot create local closing txs, waiting for remote closing_complete: ${closingResult.value.message}" } + val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(), listOf(), closingFeerate, currentBlockHeight.toLong()) + actions1.add(ChannelAction.Storage.StoreState(nextState)) + Pair(nextState, actions1) + } + is Either.Right -> { + val (closingTxs, closingComplete) = closingResult.value + val nextState = Negotiating(commitments1, localShutdown, remoteShutdown, listOf(closingTxs), listOf(), closingFeerate, currentBlockHeight.toLong()) + actions1.addAll(listOf(ChannelAction.Storage.StoreState(nextState), ChannelAction.Message.Send(closingComplete))) + Pair(nextState, actions1) + } + } } else -> { val nextState = this@ShuttingDown.copy(commitments = commitments1) @@ -128,6 +132,16 @@ data class ShuttingDown( } } } + is Shutdown -> { + if (cmd.message.scriptPubKey != remoteShutdown.scriptPubKey) { + logger.debug { "our peer updated their shutdown script (previous=${remoteShutdown.scriptPubKey}, current=${cmd.message.scriptPubKey})" } + val nextState = this@ShuttingDown.copy(remoteShutdown = cmd.message) + Pair(nextState, listOf(ChannelAction.Storage.StoreState(nextState))) + } else { + // This is a retransmission of their previous shutdown, we can ignore it. + Pair(this@ShuttingDown, listOf()) + } + } is Error -> { handleRemoteError(cmd.message) } @@ -174,7 +188,30 @@ data class ShuttingDown( is ChannelCommand.Htlc.Settlement.Fail -> handleCommandResult(cmd, commitments.sendFail(cmd, staticParams.nodeParams.nodePrivateKey), cmd.commit) is ChannelCommand.Htlc.Settlement.FailMalformed -> handleCommandResult(cmd, commitments.sendFailMalformed(cmd), cmd.commit) is ChannelCommand.Commitment.UpdateFee -> handleCommandResult(cmd, commitments.sendFee(cmd), cmd.commit) - is ChannelCommand.Close.MutualClose -> handleCommandError(cmd, ClosingAlreadyInProgress(channelId)) + is ChannelCommand.Close.MutualClose -> { + // We may be updating our closing script or feerate. + when (cmd.scriptPubKey) { + null -> when (cmd.feerate) { + null -> Pair(this@ShuttingDown, listOf()) + else -> { + val nextState = this@ShuttingDown.copy(closingFeerate = cmd.feerate) + Pair(nextState, listOf(ChannelAction.Storage.StoreState(nextState))) + } + } + else -> { + val localShutdown1 = localShutdown.copy(scriptPubKey = cmd.scriptPubKey) + val nextState = when (cmd.feerate) { + null -> this@ShuttingDown.copy(localShutdown = localShutdown1) + else -> this@ShuttingDown.copy(localShutdown = localShutdown1, closingFeerate = cmd.feerate) + } + val actions = listOf( + ChannelAction.Storage.StoreState(nextState), + ChannelAction.Message.Send(localShutdown1), + ) + Pair(nextState, actions) + } + } + } is ChannelCommand.Close.ForceClose -> handleLocalError(cmd, ForcedLocalCommit(channelId)) is ChannelCommand.WatchReceived -> when (val watch = cmd.watch) { is WatchEventConfirmed -> updateFundingTxStatus(watch) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt index 6ff1ec5e4..450c021f3 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Syncing.kt @@ -235,36 +235,9 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: } } } - // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. - // negotiation restarts from the beginning, and is initialized by the initiator - // note: in any case we still need to keep all previously sent closing_signed, because they may publish one of them - state is Negotiating && state.commitments.params.localParams.isInitiator -> { - // we could use the last closing_signed we sent, but network fees may have changed while we were offline so it is better to restart from scratch - val (closingTx, closingSigned) = Helpers.Closing.makeFirstClosingTx( - channelKeys(), - state.commitments.latest, - state.localShutdown.scriptPubKey.toByteArray(), - state.remoteShutdown.scriptPubKey.toByteArray(), - state.closingFeerates ?: ClosingFeerates(currentOnChainFeerates.mutualCloseFeerate) - ) - val closingTxProposed1 = state.closingTxProposed + listOf(listOf(ClosingTxProposed(closingTx, closingSigned))) - val nextState = state.copy(closingTxProposed = closingTxProposed1) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Message.Send(state.localShutdown), - ChannelAction.Message.Send(closingSigned) - ) - return Pair(nextState, actions) - } state is Negotiating -> { - // we start a new round of negotiation - val closingTxProposed1 = if (state.closingTxProposed.last().isEmpty()) state.closingTxProposed else state.closingTxProposed + listOf(listOf()) - val nextState = state.copy(closingTxProposed = closingTxProposed1) - val actions = listOf( - ChannelAction.Storage.StoreState(nextState), - ChannelAction.Message.Send(state.localShutdown) - ) - return Pair(nextState, actions) + // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. + return Pair(this@Syncing, listOf(ChannelAction.Message.Send(state.localShutdown))) } else -> unhandled(cmd) } @@ -282,21 +255,20 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: is ChannelCommand.WatchReceived -> when (state) { is ChannelStateWithCommitments -> when (val watch = cmd.watch) { is WatchEventSpent -> when { - state is Negotiating && state.closingTxProposed.flatten().any { it.unsignedTx.tx.txid == watch.tx.txid } -> { - logger.info { "closing tx published: closingTxId=${watch.tx.txid}" } - val closingTx = state.getMutualClosePublished(watch.tx) - val nextState = Closing( - state.commitments, - waitingSinceBlock = currentBlockHeight.toLong(), - mutualCloseProposed = state.closingTxProposed.flatten().map { it.unsignedTx }, - mutualClosePublished = listOf(closingTx) - ) + state is Negotiating && state.publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // This is one of the transactions we already published, we have nothing to do. + Pair(this@Syncing, listOf()) + } + state is Negotiating && state.proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // They published one of our closing transactions without sending us their signature. + val closingTx = state.run { getMutualClosePublished(watch.tx) } + val nextState = state.copy(publishedClosingTxs = state.publishedClosingTxs + closingTx) val actions = listOf( ChannelAction.Storage.StoreState(nextState), ChannelAction.Blockchain.PublishTx(closingTx), ChannelAction.Blockchain.SendWatch(WatchConfirmed(channelId, watch.tx, staticParams.nodeParams.minDepthBlocks.toLong(), BITCOIN_TX_CONFIRMED(watch.tx))) ) - Pair(nextState, actions) + Pair(this@Syncing.copy(state = nextState), actions) } else -> { val (nextState, actions) = state.run { handlePotentialForceClose(watch) } @@ -307,8 +279,8 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: } } } - is WatchEventConfirmed -> { - if (watch.event is BITCOIN_FUNDING_DEPTHOK) { + is WatchEventConfirmed -> when { + watch.event is BITCOIN_FUNDING_DEPTHOK -> { when (val res = state.run { acceptFundingTxConfirmed(watch) }) { is Either.Left -> Pair(this@Syncing, listOf()) is Either.Right -> { @@ -326,9 +298,16 @@ data class Syncing(val state: PersistedChannelState, val channelReestablishSent: Pair(this@Syncing.copy(state = nextState), actions + listOf(ChannelAction.Storage.StoreState(nextState))) } } - } else { - Pair(this@Syncing, listOf()) } + state is Negotiating && state.publishedClosingTxs.any { it.tx.txid == watch.tx.txid } -> { + // One of our published transactions confirmed, the channel is now closed. + state.run { completeMutualClose(publishedClosingTxs.first { it.tx.txid == watch.tx.txid }) } + } + state is Negotiating && state.proposedClosingTxs.flatMap { it.all }.any { it.tx.txid == watch.tx.txid } -> { + // A transaction that we proposed for which they didn't send us their signature was confirmed, the channel is now closed. + state.run { completeMutualClose(getMutualClosePublished(watch.tx)) } + } + else -> Pair(this@Syncing, listOf()) } } is WaitForFundingSigned -> Pair(this@Syncing, listOf()) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 4b40d2113..702f81e96 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -7,7 +7,6 @@ // serialization code in this file. JsonSerializers.CommitmentSerializer::class, JsonSerializers.CommitmentsSerializer::class, - JsonSerializers.ClosingFeeratesSerializer::class, JsonSerializers.LocalParamsSerializer::class, JsonSerializers.RemoteParamsSerializer::class, JsonSerializers.LocalCommitSerializer::class, @@ -41,7 +40,6 @@ JsonSerializers.TransactionSerializer::class, JsonSerializers.OutPointSerializer::class, JsonSerializers.TxOutSerializer::class, - JsonSerializers.ClosingTxProposedSerializer::class, JsonSerializers.LocalCommitPublishedSerializer::class, JsonSerializers.RemoteCommitPublishedSerializer::class, JsonSerializers.RevokedCommitPublishedSerializer::class, @@ -70,7 +68,8 @@ JsonSerializers.LocalFundingStatusSerializer::class, JsonSerializers.RemoteFundingStatusSerializer::class, JsonSerializers.ShutdownSerializer::class, - JsonSerializers.ClosingSignedSerializer::class, + JsonSerializers.ClosingCompleteSerializer::class, + JsonSerializers.ClosingSigSerializer::class, JsonSerializers.UpdateAddHtlcSerializer::class, JsonSerializers.CommitSigSerializer::class, JsonSerializers.EncryptedChannelDataSerializer::class, @@ -78,12 +77,12 @@ JsonSerializers.FundingCreatedSerializer::class, JsonSerializers.ChannelReadySerializer::class, JsonSerializers.ChannelReadyTlvShortChannelIdTlvSerializer::class, - JsonSerializers.ClosingSignedTlvFeeRangeSerializer::class, JsonSerializers.ShutdownTlvChannelDataSerializer::class, JsonSerializers.GenericTlvSerializer::class, JsonSerializers.TlvStreamSerializer::class, JsonSerializers.ShutdownTlvSerializer::class, - JsonSerializers.ClosingSignedTlvSerializer::class, + JsonSerializers.ClosingCompleteTlvSerializer::class, + JsonSerializers.ClosingSigTlvSerializer::class, JsonSerializers.ChannelReestablishTlvSerializer::class, JsonSerializers.ChannelReadyTlvSerializer::class, JsonSerializers.CommitSigTlvAlternativeFeerateSigSerializer::class, @@ -196,7 +195,6 @@ object JsonSerializers { subclass(CommitSigTlv.AlternativeFeerateSigs::class, CommitSigTlvAlternativeFeerateSigsSerializer) subclass(CommitSigTlv.Batch::class, CommitSigTlvBatchSerializer) subclass(ShutdownTlv.ChannelData::class, ShutdownTlvChannelDataSerializer) - subclass(ClosingSignedTlv.FeeRange::class, ClosingSignedTlvFeeRangeSerializer) } // TODO The following declarations are required because serializers for [TransactionWithInputInfo] // depend themselves on @Contextual serializers. Once we get rid of v2/v3 serialization and we @@ -333,9 +331,6 @@ object JsonSerializers { @Serializer(forClass = Commitments::class) object CommitmentsSerializer - @Serializer(forClass = ClosingFeerates::class) - object ClosingFeeratesSerializer - @Serializer(forClass = LocalParams::class) object LocalParamsSerializer @@ -439,9 +434,6 @@ object JsonSerializers { delegateSerializer = KeyPathSerializer ) - @Serializer(forClass = ClosingTxProposed::class) - object ClosingTxProposedSerializer - @Serializer(forClass = LocalCommitPublished::class) object LocalCommitPublishedSerializer @@ -481,8 +473,11 @@ object JsonSerializers { @Serializer(forClass = Shutdown::class) object ShutdownSerializer - @Serializer(forClass = ClosingSigned::class) - object ClosingSignedSerializer + @Serializer(forClass = ClosingComplete::class) + object ClosingCompleteSerializer + + @Serializer(forClass = ClosingSig::class) + object ClosingSigSerializer @Serializer(forClass = CommitSig::class) object CommitSigSerializer @@ -499,9 +494,6 @@ object JsonSerializers { @Serializer(forClass = ChannelReadyTlv.ShortChannelIdTlv::class) object ChannelReadyTlvShortChannelIdTlvSerializer - @Serializer(forClass = ClosingSignedTlv.FeeRange::class) - object ClosingSignedTlvFeeRangeSerializer - @Serializer(forClass = ShutdownTlv.ChannelData::class) object ShutdownTlvChannelDataSerializer @@ -520,8 +512,11 @@ object JsonSerializers { @Serializer(forClass = CommitSigTlv::class) object CommitSigTlvSerializer - @Serializer(forClass = ClosingSignedTlv::class) - object ClosingSignedTlvSerializer + @Serializer(forClass = ClosingCompleteTlv::class) + object ClosingCompleteTlvSerializer + + @Serializer(forClass = ClosingSigTlv::class) + object ClosingSigTlvSerializer @Serializer(forClass = ChannelReadyTlv::class) object ChannelReadyTlvSerializer 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 90c1e8974..34dc29b59 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/ChannelState.kt @@ -14,7 +14,8 @@ PublicKeyKSerializer::class, PrivateKeyKSerializer::class, ShutdownSerializer::class, - ClosingSignedSerializer::class, + ClosingCompleteSerializer::class, + ClosingSigSerializer::class, SatoshiKSerializer::class, UpdateAddHtlcSerializer::class, CommitSigSerializer::class, @@ -23,7 +24,8 @@ FundingCreatedSerializer::class, CommitSigTlvSerializer::class, ShutdownTlvSerializer::class, - ClosingSignedTlvSerializer::class, + ClosingCompleteTlvSerializer::class, + ClosingSigTlvSerializer::class, ChannelReadyTlvSerializer::class, ChannelReestablishTlvSerializer::class, TlvStreamSerializer::class, @@ -67,8 +69,11 @@ object FundingSignedSerializer @Serializer(forClass = Shutdown::class) object ShutdownSerializer -@Serializer(forClass = ClosingSigned::class) -object ClosingSignedSerializer +@Serializer(forClass = ClosingComplete::class) +object ClosingCompleteSerializer + +@Serializer(forClass = ClosingSig::class) +object ClosingSigSerializer @Serializer(forClass = ChannelUpdate::class) object ChannelUpdateSerializer @@ -112,9 +117,6 @@ object FundingCreatedSerializer @Serializer(forClass = ChannelReadyTlv.ShortChannelIdTlv::class) object ChannelReadyTlvShortChannelIdTlvSerializer -@Serializer(forClass = ClosingSignedTlv.FeeRange::class) -object ClosingSignedTlvFeeRangeSerializer - @Serializer(forClass = ShutdownTlv.ChannelData::class) object ShutdownTlvChannelDataSerializer @@ -124,8 +126,11 @@ object ShutdownTlvSerializer @Serializer(forClass = CommitSigTlv::class) object CommitSigTlvSerializer -@Serializer(forClass = ClosingSignedTlv::class) -object ClosingSignedTlvSerializer +@Serializer(forClass = ClosingCompleteTlv::class) +object ClosingCompleteTlvSerializer + +@Serializer(forClass = ClosingSigTlv::class) +object ClosingSigTlvSerializer @Serializer(forClass = ChannelReadyTlv::class) object ChannelReadyTlvSerializer @@ -358,9 +363,10 @@ internal data class ChannelVersion(val bits: ByteVector) { } @Serializable -internal data class ClosingTxProposed(val unsignedTx: Transactions.TransactionWithInputInfo.ClosingTx, val localClosingSigned: ClosingSigned) { - fun export() = fr.acinq.lightning.channel.ClosingTxProposed(unsignedTx, localClosingSigned) -} +internal data class ClosingSigned(val channelId: ByteVector32, val feeSatoshis: Satoshi, val signature: ByteVector64) + +@Serializable +internal data class ClosingTxProposed(val unsignedTx: Transactions.TransactionWithInputInfo.ClosingTx, val localClosingSigned: ClosingSigned) @Serializable internal data class Commitments( @@ -553,9 +559,10 @@ internal data class Negotiating( commitments.export(), localShutdown, remoteShutdown, - closingTxProposed.map { x -> x.map { it.export() } }, - bestUnpublishedClosingTx, - null + listOf(), + listOf(), + null, + 0 ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/Serialization.kt index 7099fc9c3..ec4e25460 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v2/Serialization.kt @@ -44,7 +44,6 @@ object Serialization { private val tlvSerializersModule = SerializersModule { polymorphic(Tlv::class) { subclass(ChannelReadyTlvShortChannelIdTlvSerializer) - subclass(ClosingSignedTlvFeeRangeSerializer) subclass(ShutdownTlvChannelDataSerializer) subclass(GenericTlvSerializer) } 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 0a548543d..ecec019ba 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/ChannelState.kt @@ -14,7 +14,8 @@ PublicKeyKSerializer::class, PrivateKeyKSerializer::class, ShutdownSerializer::class, - ClosingSignedSerializer::class, + ClosingCompleteSerializer::class, + ClosingSigSerializer::class, SatoshiKSerializer::class, UpdateAddHtlcSerializer::class, CommitSigSerializer::class, @@ -23,7 +24,8 @@ FundingCreatedSerializer::class, CommitSigTlvSerializer::class, ShutdownTlvSerializer::class, - ClosingSignedTlvSerializer::class, + ClosingCompleteTlvSerializer::class, + ClosingSigTlvSerializer::class, ChannelReadyTlvSerializer::class, ChannelReestablishTlvSerializer::class, TlvStreamSerializer::class, @@ -40,7 +42,10 @@ package fr.acinq.lightning.serialization.v3 import fr.acinq.bitcoin.* -import fr.acinq.lightning.* +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Features +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.InteractiveTxOutput import fr.acinq.lightning.channel.SpliceStatus @@ -67,8 +72,11 @@ object FundingSignedSerializer @Serializer(forClass = Shutdown::class) object ShutdownSerializer -@Serializer(forClass = ClosingSigned::class) -object ClosingSignedSerializer +@Serializer(forClass = ClosingComplete::class) +object ClosingCompleteSerializer + +@Serializer(forClass = ClosingSig::class) +object ClosingSigSerializer @Serializer(forClass = ChannelUpdate::class) object ChannelUpdateSerializer @@ -112,9 +120,6 @@ object FundingCreatedSerializer @Serializer(forClass = ChannelReadyTlv.ShortChannelIdTlv::class) object ChannelReadyTlvShortChannelIdTlvSerializer -@Serializer(forClass = ClosingSignedTlv.FeeRange::class) -object ClosingSignedTlvFeeRangeSerializer - @Serializer(forClass = ShutdownTlv.ChannelData::class) object ShutdownTlvChannelDataSerializer @@ -124,8 +129,11 @@ object ShutdownTlvSerializer @Serializer(forClass = CommitSigTlv::class) object CommitSigTlvSerializer -@Serializer(forClass = ClosingSignedTlv::class) -object ClosingSignedTlvSerializer +@Serializer(forClass = ClosingCompleteTlv::class) +object ClosingCompleteTlvSerializer + +@Serializer(forClass = ClosingSigTlv::class) +object ClosingSigTlvSerializer @Serializer(forClass = ChannelReadyTlv::class) object ChannelReadyTlvSerializer @@ -345,14 +353,13 @@ internal data class ChannelFeatures(val bin: ByteVector) { } @Serializable -internal data class ClosingFeerates(val preferred: FeeratePerKw, val min: FeeratePerKw, val max: FeeratePerKw) { - fun export() = fr.acinq.lightning.channel.states.ClosingFeerates(preferred, min, max) -} +internal data class ClosingSigned(val channelId: ByteVector32, val feeSatoshis: Satoshi, val signature: ByteVector64) @Serializable -internal data class ClosingTxProposed(val unsignedTx: Transactions.TransactionWithInputInfo.ClosingTx, val localClosingSigned: ClosingSigned) { - fun export() = fr.acinq.lightning.channel.ClosingTxProposed(unsignedTx, localClosingSigned) -} +internal data class ClosingFeerates(val preferred: FeeratePerKw, val min: FeeratePerKw, val max: FeeratePerKw) + +@Serializable +internal data class ClosingTxProposed(val unsignedTx: Transactions.TransactionWithInputInfo.ClosingTx, val localClosingSigned: ClosingSigned) @Serializable internal data class Commitments( @@ -397,7 +404,15 @@ internal data class Commitments( // We will put a WatchConfirmed when starting, which will return the confirmed transaction. fr.acinq.lightning.channel.LocalFundingStatus.UnconfirmedFundingTx( fr.acinq.lightning.channel.PartiallySignedSharedTransaction( - fr.acinq.lightning.channel.SharedTransaction(null, InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote, 0.msat), listOf(), listOf(), listOf(), listOf(), 0), + fr.acinq.lightning.channel.SharedTransaction( + null, + InteractiveTxOutput.Shared(0, commitInput.txOut.publicKeyScript, localCommit.spec.toLocal, localCommit.spec.toRemote, 0.msat), + listOf(), + listOf(), + listOf(), + listOf(), + 0 + ), // We must correctly set the txId here. TxSignatures(channelId, commitInput.outPoint.txid, listOf()), ), @@ -512,7 +527,7 @@ internal data class Normal( remoteChannelUpdate, localShutdown, remoteShutdown, - closingFeerates?.export(), + closingFeerates?.preferred, SpliceStatus.None, listOf(), ) @@ -532,7 +547,7 @@ internal data class ShuttingDown( commitments.export(), localShutdown, remoteShutdown, - closingFeerates?.export() + closingFeerates?.preferred ) } @@ -557,9 +572,10 @@ internal data class Negotiating( commitments.export(), localShutdown, remoteShutdown, - closingTxProposed.map { x -> x.map { it.export() } }, - bestUnpublishedClosingTx, - closingFeerates?.export() + listOf(), + listOf(), + closingFeerates?.preferred, + 0 ) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/Serialization.kt index 6df2513f3..34a20fb20 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v3/Serialization.kt @@ -44,7 +44,6 @@ object Serialization { private val tlvSerializersModule = SerializersModule { polymorphic(Tlv::class) { subclass(ChannelReadyTlvShortChannelIdTlvSerializer) - subclass(ClosingSignedTlvFeeRangeSerializer) subclass(ShutdownTlvChannelDataSerializer) subclass(GenericTlvSerializer) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 3de5553f3..11bc0fa7a 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -30,13 +30,16 @@ object Deserialization { 0x09 -> readLegacyWaitForFundingLocked() 0x00 -> readWaitForFundingConfirmed() 0x01 -> readWaitForChannelReady() - 0x02 -> readNormal() - 0x03 -> readShuttingDown() - 0x04 -> readNegotiating() + 0x02 -> readNormal02() + 0x03 -> readShuttingDown03() + 0x04 -> readNegotiating04() 0x05 -> readClosing() 0x06 -> readWaitForRemotePublishFutureCommitment() 0x07 -> readClosed() 0x0a -> readWaitForFundingSigned() + 0x0b -> readNormal() + 0x0c -> readShuttingDown() + 0x0d -> readNegotiating() else -> error("unknown discriminator $discriminator for class ${PersistedChannelState::class}") } @@ -85,6 +88,37 @@ object Deserialization { lastSent = readLightningMessage() as ChannelReady ) + private fun Input.readNormal02(): Normal = Normal( + commitments = readCommitments(), + shortChannelId = ShortChannelId(readNumber()), + channelUpdate = readLightningMessage() as ChannelUpdate, + remoteChannelUpdate = readNullable { readLightningMessage() as ChannelUpdate }, + localShutdown = readNullable { readLightningMessage() as Shutdown }, + remoteShutdown = readNullable { readLightningMessage() as Shutdown }, + closingFeerate = readNullable { + // We used to store three closing feerates for fee range negotiation. + val preferred = FeeratePerKw(readNumber().sat) + readNumber() + readNumber() + preferred + }, + spliceStatus = when (val discriminator = read()) { + 0x00 -> SpliceStatus.None + 0x01 -> SpliceStatus.WaitingForSigs( + session = readInteractiveTxSigningSession(), + origins = readCollection { readChannelOrigin() as Origin.PayToOpenOrigin }.toList() + ) + else -> error("unknown discriminator $discriminator for class ${SpliceStatus::class}") + }, + liquidityLeases = when { + availableBytes == 0 -> listOf() + else -> when (val discriminator = read()) { + 0x01 -> readCollection { readLiquidityLease() }.toList() + else -> error("unknown discriminator $discriminator for class ${Normal::class}") + } + } + ) + private fun Input.readNormal(): Normal = Normal( commitments = readCommitments(), shortChannelId = ShortChannelId(readNumber()), @@ -92,7 +126,7 @@ object Deserialization { remoteChannelUpdate = readNullable { readLightningMessage() as ChannelUpdate }, localShutdown = readNullable { readLightningMessage() as Shutdown }, remoteShutdown = readNullable { readLightningMessage() as Shutdown }, - closingFeerates = readNullable { readClosingFeerates() }, + closingFeerate = readNullable { FeeratePerKw(readNumber().sat) }, spliceStatus = when (val discriminator = read()) { 0x00 -> SpliceStatus.None 0x01 -> SpliceStatus.WaitingForSigs( @@ -110,27 +144,56 @@ object Deserialization { } ) - private fun Input.readShuttingDown(): ShuttingDown = ShuttingDown( + private fun Input.readShuttingDown03(): ShuttingDown = ShuttingDown( commitments = readCommitments(), localShutdown = readLightningMessage() as Shutdown, remoteShutdown = readLightningMessage() as Shutdown, - closingFeerates = readNullable { readClosingFeerates() } + closingFeerate = readNullable { + // We used to store three closing feerates for fee range negotiation. + val preferred = FeeratePerKw(readNumber().sat) + readNumber() + readNumber() + preferred + } ) - private fun Input.readNegotiating(): Negotiating = Negotiating( + private fun Input.readShuttingDown(): ShuttingDown = ShuttingDown( commitments = readCommitments(), localShutdown = readLightningMessage() as Shutdown, remoteShutdown = readLightningMessage() as Shutdown, - closingTxProposed = readCollection { + closingFeerate = readNullable { FeeratePerKw(readNumber().sat) } + ) + + private fun Input.readNegotiating04(): Negotiating { + val commitments = readCommitments() + val localShutdown = readLightningMessage() as Shutdown + val remoteShutdown = readLightningMessage() as Shutdown + // We cannot convert the closing transactions created with the old closing protocol to the new one. + // We simply ignore them, which will lead to a force-close if one of the proposed transactions is published. + readCollection { readCollection { - ClosingTxProposed( - unsignedTx = readTransactionWithInputInfo() as ClosingTx, - localClosingSigned = readLightningMessage() as ClosingSigned - ) + readTransactionWithInputInfo() // unsigned closing tx + readDelimitedByteArray() // closing_signed message }.toList() + }.toList() + val bestUnpublishedClosingTx = readNullable { readTransactionWithInputInfo() as ClosingTx } + return Negotiating(commitments, localShutdown, remoteShutdown, listOf(), listOfNotNull(bestUnpublishedClosingTx), closingFeerate = null, waitingSinceBlock = 0) + } + + private fun Input.readNegotiating(): Negotiating = Negotiating( + commitments = readCommitments(), + localShutdown = readLightningMessage() as Shutdown, + remoteShutdown = readLightningMessage() as Shutdown, + proposedClosingTxs = readCollection { + Transactions.ClosingTxs( + readNullable { readTransactionWithInputInfo() as ClosingTx }, + readNullable { readTransactionWithInputInfo() as ClosingTx }, + readNullable { readTransactionWithInputInfo() as ClosingTx }, + ) }.toList(), - bestUnpublishedClosingTx = readNullable { readTransactionWithInputInfo() as ClosingTx }, - closingFeerates = readNullable { readClosingFeerates() } + publishedClosingTxs = readCollection { readTransactionWithInputInfo() as ClosingTx }.toList(), + closingFeerate = readNullable { FeeratePerKw(readNumber().sat) }, + waitingSinceBlock = readNumber() ) private fun Input.readClosing(): Closing = Closing( @@ -591,12 +654,6 @@ object Deserialization { else -> error("unknown discriminator $discriminator for class ${Transactions.TransactionWithInputInfo::class}") } - private fun Input.readClosingFeerates(): ClosingFeerates = ClosingFeerates( - preferred = FeeratePerKw(readNumber().sat), - min = FeeratePerKw(readNumber().sat), - max = FeeratePerKw(readNumber().sat) - ) - private fun Input.readNumber(): Long = LightningCodecs.bigSize(this) private fun Input.readBoolean(): Boolean = read() == 1 diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index d2c124804..a39b0ffe5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -56,13 +56,13 @@ object Serialization { write(0x01); writeWaitForChannelReady(o) } is Normal -> { - write(0x02); writeNormal(o) + write(0x0b); writeNormal(o) } is ShuttingDown -> { - write(0x03); writeShuttingDown(o) + write(0x0c); writeShuttingDown(o) } is Negotiating -> { - write(0x04); writeNegotiating(o) + write(0x0d); writeNegotiating(o) } is Closing -> { write(0x05); writeClosing(o) @@ -134,7 +134,7 @@ object Serialization { writeNullable(remoteChannelUpdate) { writeLightningMessage(it) } writeNullable(localShutdown) { writeLightningMessage(it) } writeNullable(remoteShutdown) { writeLightningMessage(it) } - writeNullable(closingFeerates) { writeClosingFeerates(it) } + writeNullable(closingFeerate) { writeNumber(it.toLong()) } when (spliceStatus) { is SpliceStatus.WaitingForSigs -> { write(0x01) @@ -153,23 +153,21 @@ object Serialization { writeCommitments(commitments) writeLightningMessage(localShutdown) writeLightningMessage(remoteShutdown) - writeNullable(closingFeerates) { writeClosingFeerates(it) } + writeNullable(closingFeerate) { writeNumber(it.toLong()) } } private fun Output.writeNegotiating(o: Negotiating) = o.run { writeCommitments(commitments) writeLightningMessage(localShutdown) writeLightningMessage(remoteShutdown) - writeCollection(closingTxProposed) { - writeCollection(it) { closingTxProposed -> - closingTxProposed.run { - writeTransactionWithInputInfo(unsignedTx) - writeLightningMessage(localClosingSigned) - } - } + writeCollection(proposedClosingTxs) { + writeNullable(it.localAndRemote) { tx -> writeTransactionWithInputInfo(tx) } + writeNullable(it.localOnly) { tx -> writeTransactionWithInputInfo(tx) } + writeNullable(it.remoteOnly) { tx -> writeTransactionWithInputInfo(tx) } } - writeNullable(bestUnpublishedClosingTx) { writeTransactionWithInputInfo(it) } - writeNullable(closingFeerates) { writeClosingFeerates(it) } + writeCollection(publishedClosingTxs) { writeTransactionWithInputInfo(it) } + writeNullable(closingFeerate) { writeNumber(it.toLong()) } + writeNumber(waitingSinceBlock) } private fun Output.writeClosing(o: Closing) = o.run { @@ -667,12 +665,6 @@ object Serialization { } } - private fun Output.writeClosingFeerates(o: ClosingFeerates): Unit = o.run { - writeNumber(preferred.toLong()) - writeNumber(min.toLong()) - writeNumber(max.toLong()) - } - private fun Output.writeNumber(o: Number): Unit = LightningCodecs.writeBigSize(o.toLong(), this) private fun Output.writeBoolean(o: Boolean): Unit = if (o) write(1) else write(0) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt index b2be5c414..fb8457064 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/transactions/Transactions.kt @@ -137,6 +137,17 @@ object Transactions { } } + sealed class ClosingTxFee { + data class PaidByUs(val fee: Satoshi) : ClosingTxFee() + data class PaidByThem(val fee: Satoshi) : ClosingTxFee() + } + + @Serializable + data class ClosingTxs(val localAndRemote: TransactionWithInputInfo.ClosingTx?, val localOnly: TransactionWithInputInfo.ClosingTx?, val remoteOnly: TransactionWithInputInfo.ClosingTx?) { + val preferred: TransactionWithInputInfo.ClosingTx? = localAndRemote ?: localOnly ?: remoteOnly + val all: List = listOfNotNull(localAndRemote, localOnly, remoteOnly) + } + /** * When *local* *current* [[CommitTx]] is published: * - [[ClaimDelayedOutputTx]] spends to-local output of [[CommitTx]] after a delay @@ -731,39 +742,44 @@ object Transactions { } } - fun makeClosingTx( - commitTxInput: InputInfo, - localScriptPubKey: ByteArray, - remoteScriptPubKey: ByteArray, - localIsInitiator: Boolean, - dustLimit: Satoshi, - closingFee: Satoshi, - spec: CommitmentSpec - ): TransactionWithInputInfo.ClosingTx { + fun makeClosingTxs(input: InputInfo, spec: CommitmentSpec, fee: ClosingTxFee, sequence: Long, lockTime: Long, localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector): ClosingTxs { require(spec.htlcs.isEmpty()) { "there shouldn't be any pending htlcs" } - val (toLocalAmount, toRemoteAmount) = if (localIsInitiator) { - Pair(spec.toLocal.truncateToSatoshi() - closingFee, spec.toRemote.truncateToSatoshi()) - } else { - Pair(spec.toLocal.truncateToSatoshi(), spec.toRemote.truncateToSatoshi() - closingFee) + val (toLocalAmount, toRemoteAmount) = when (fee) { + is ClosingTxFee.PaidByUs -> Pair(spec.toLocal.truncateToSatoshi() - fee.fee, spec.toRemote.truncateToSatoshi()) + is ClosingTxFee.PaidByThem -> Pair(spec.toLocal.truncateToSatoshi(), spec.toRemote.truncateToSatoshi() - fee.fee) } // NB: we don't care if values are < 0, they will be trimmed if they are < dust limit anyway - val toLocalOutputOpt = toLocalAmount.takeIf { it >= dustLimit }?.let { TxOut(it, localScriptPubKey) } - val toRemoteOutputOpt = toRemoteAmount.takeIf { it >= dustLimit }?.let { TxOut(it, remoteScriptPubKey) } + val txNoOutput = Transaction(2, listOf(TxIn(input.outPoint, listOf(), sequence)), listOf(), lockTime) + val toLocalOutput = if (toLocalAmount >= dustLimit(localScriptPubKey)) TxOut(toLocalAmount, localScriptPubKey) else null + val toRemoteOutput = if (toRemoteAmount >= dustLimit(remoteScriptPubKey)) TxOut(toRemoteAmount, remoteScriptPubKey) else null - val tx = LexicographicalOrdering.sort( - Transaction( - version = 2, - txIn = listOf(TxIn(commitTxInput.outPoint, ByteVector.empty, sequence = 0xffffffffL)), - txOut = listOfNotNull(toLocalOutputOpt, toRemoteOutputOpt), - lockTime = 0 + return when { + toLocalOutput != null && toRemoteOutput != null -> { + val txLocalAndRemote = LexicographicalOrdering.sort(txNoOutput.copy(txOut = listOf(toLocalOutput, toRemoteOutput))) + val toLocalIndex = when (val i = findPubKeyScriptIndex(txLocalAndRemote, localScriptPubKey.toByteArray())) { + is TxResult.Skipped -> null + is TxResult.Success -> i.result + } + ClosingTxs( + localAndRemote = TransactionWithInputInfo.ClosingTx(input, txLocalAndRemote, toLocalIndex), + // We also provide a version of the transaction without the remote output, which they may want to omit if not economical to spend. + localOnly = TransactionWithInputInfo.ClosingTx(input, txNoOutput.copy(txOut = listOf(toLocalOutput)), 0), + remoteOnly = null + ) + } + toLocalOutput != null -> ClosingTxs( + localAndRemote = null, + localOnly = TransactionWithInputInfo.ClosingTx(input, txNoOutput.copy(txOut = listOf(toLocalOutput)), 0), + remoteOnly = null, ) - ) - val toLocalOutput = when (val toLocalIndex = findPubKeyScriptIndex(tx, localScriptPubKey)) { - is TxResult.Skipped -> null - is TxResult.Success -> toLocalIndex.result + toRemoteOutput != null -> ClosingTxs( + localAndRemote = null, + localOnly = null, + remoteOnly = TransactionWithInputInfo.ClosingTx(input, txNoOutput.copy(txOut = listOf(toRemoteOutput)), null) + ) + else -> ClosingTxs(localAndRemote = null, localOnly = null, remoteOnly = null) } - return TransactionWithInputInfo.ClosingTx(commitTxInput, tx, toLocalOutput) } private fun findPubKeyScriptIndex(tx: Transaction, pubkeyScript: ByteArray): TxResult { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index 76a268efd..101b937ae 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -314,32 +314,6 @@ sealed class ShutdownTlv : Tlv { } } -sealed class ClosingSignedTlv : Tlv { - data class FeeRange(val min: Satoshi, 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))) - } - } - - data class ChannelData(val ecb: EncryptedChannelData) : ClosingSignedTlv() { - override val tag: Long get() = ChannelData.tag - override fun write(out: Output) = LightningCodecs.writeBytes(ecb.data, out) - - companion object : TlvValueReader { - const val tag: Long = 0x47010000 - override fun read(input: Input): ChannelData = ChannelData(EncryptedChannelData(LightningCodecs.bytes(input, input.availableBytes).toByteVector())) - } - } -} - sealed class ClosingCompleteTlv : Tlv { /** Signature for a closing transaction containing only the closer's output. */ data class CloserNoClosee(val sig: ByteVector64) : ClosingCompleteTlv() { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 88ef08f03..10ce99836 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -74,7 +74,6 @@ interface LightningMessage { ChannelAnnouncement.type -> ChannelAnnouncement.read(stream) ChannelUpdate.type -> ChannelUpdate.read(stream) Shutdown.type -> Shutdown.read(stream) - ClosingSigned.type -> ClosingSigned.read(stream) ClosingComplete.type -> ClosingComplete.read(stream) ClosingSig.type -> ClosingSig.read(stream) OnionMessage.type -> OnionMessage.read(stream) @@ -1483,44 +1482,6 @@ data class Shutdown( } } -data class ClosingSigned( - override val channelId: ByteVector32, - val feeSatoshis: Satoshi, - val signature: ByteVector64, - val tlvStream: TlvStream = TlvStream.empty() -) : ChannelMessage, HasChannelId, HasEncryptedChannelData { - override val type: Long get() = ClosingSigned.type - - override val channelData: EncryptedChannelData get() = tlvStream.get()?.ecb ?: EncryptedChannelData.empty - override fun withNonEmptyChannelData(ecd: EncryptedChannelData): ClosingSigned = copy(tlvStream = tlvStream.addOrUpdate(ClosingSignedTlv.ChannelData(ecd))) - - override fun write(out: Output) { - LightningCodecs.writeBytes(channelId, out) - LightningCodecs.writeU64(feeSatoshis.toLong(), out) - LightningCodecs.writeBytes(signature, out) - TlvStreamSerializer(false, readers).write(tlvStream, out) - } - - companion object : LightningMessageReader { - const val type: Long = 39 - - @Suppress("UNCHECKED_CAST") - 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( - ByteVector32(LightningCodecs.bytes(input, 32)), - Satoshi(LightningCodecs.u64(input)), - ByteVector64(LightningCodecs.bytes(input, 64)), - TlvStreamSerializer(false, readers).read(input) - ) - } - } -} - data class ClosingComplete( override val channelId: ByteVector32, val fees: Satoshi, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index 0918082fa..bd480dc5a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -11,7 +11,8 @@ import fr.acinq.lightning.channel.states.* import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.json.JsonSerializers -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.payment.OutgoingPaymentPacket import fr.acinq.lightning.router.ChannelHop import fr.acinq.lightning.serialization.Serialization @@ -230,38 +231,60 @@ object TestsHelper { return Triple(alice1, bob1, fundingTx) } - fun mutualCloseAlice(alice: LNChannel, bob: LNChannel, scriptPubKey: ByteVector? = null, feerates: ClosingFeerates? = null): Triple, LNChannel, ClosingSigned> { - val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(scriptPubKey, feerates)) + fun mutualCloseAlice(alice: LNChannel, bob: LNChannel, scriptPubKey: ByteVector? = null, closingFeerate: FeeratePerKw? = null): Triple, LNChannel, Transaction> { + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(scriptPubKey, closingFeerate)) assertIs>(alice1) val shutdownAlice = actionsAlice1.findOutgoingMessage() - assertNull(actionsAlice1.findOutgoingMessageOpt()) + assertNull(actionsAlice1.findOutgoingMessageOpt()) val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(shutdownAlice)) assertIs>(bob1) val shutdownBob = actionsBob1.findOutgoingMessage() - assertNull(actionsBob1.findOutgoingMessageOpt()) + actionsBob1.findOutgoingMessage() val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) assertIs>(alice2) - val closingSignedAlice = actionsAlice2.findOutgoingMessage() - return Triple(alice2, bob1, closingSignedAlice) + val closingComplete = actionsAlice2.findOutgoingMessage() + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs>(bob2) + val closingSig = actionsBob2.findOutgoingMessage() + + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(closingSig)) + assertIs>(alice3) + val closingTx = actionsAlice3.findPublishTxs().first() + actionsAlice3.hasWatchConfirmed(closingTx.txid) + val commitInput = alice1.commitments.latest.commitInput + Transaction.correctlySpends(closingTx, mapOf(commitInput.outPoint to commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + return Triple(alice3, bob2, closingTx) } - fun mutualCloseBob(alice: LNChannel, bob: LNChannel, scriptPubKey: ByteVector? = null, feerates: ClosingFeerates? = null): Triple, LNChannel, ClosingSigned> { - val (bob1, actionsBob1) = bob.process(ChannelCommand.Close.MutualClose(scriptPubKey, feerates)) + fun mutualCloseBob(alice: LNChannel, bob: LNChannel, scriptPubKey: ByteVector? = null, closingFeerate: FeeratePerKw? = null): Triple, LNChannel, Transaction> { + val (bob1, actionsBob1) = bob.process(ChannelCommand.Close.MutualClose(scriptPubKey, closingFeerate)) assertIs>(bob1) val shutdownBob = actionsBob1.findOutgoingMessage() - assertNull(actionsBob1.findOutgoingMessageOpt()) + assertNull(actionsBob1.findOutgoingMessageOpt()) val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(shutdownBob)) assertIs>(alice1) val shutdownAlice = actionsAlice1.findOutgoingMessage() - val closingSignedAlice = actionsAlice1.findOutgoingMessage() + actionsAlice1.findOutgoingMessage() val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(shutdownAlice)) assertIs>(bob2) - assertNull(actionsBob2.findOutgoingMessageOpt()) - return Triple(alice1, bob2, closingSignedAlice) + val closingComplete = actionsBob2.findOutgoingMessage() + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs>(alice2) + val closingSig = actionsAlice2.findOutgoingMessage() + + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(closingSig)) + assertIs>(bob3) + val closingTx = actionsBob3.findPublishTxs().first() + actionsBob3.hasWatchConfirmed(closingTx.txid) + val commitInput = alice1.commitments.latest.commitInput + Transaction.correctlySpends(closingTx, mapOf(commitInput.outPoint to commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + return Triple(alice2, bob3, closingTx) } fun localClose(s: LNChannel): Pair, LocalCommitPublished> { 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 63af10dcb..e21548db6 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -1,12 +1,10 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* -import fr.acinq.bitcoin.Bitcoin.computeP2PkhAddress import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Feature import fr.acinq.lightning.Lightning import fr.acinq.lightning.blockchain.* -import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.TestsHelper.addHtlc import fr.acinq.lightning.channel.TestsHelper.claimHtlcSuccessTxs @@ -18,8 +16,6 @@ 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.mutualCloseAlice -import fr.acinq.lightning.channel.TestsHelper.mutualCloseBob import fr.acinq.lightning.channel.TestsHelper.reachNormal import fr.acinq.lightning.channel.TestsHelper.remoteClose import fr.acinq.lightning.channel.TestsHelper.useAlternativeCommitSig @@ -28,7 +24,10 @@ import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.transactions.Scripts import fr.acinq.lightning.transactions.Transactions -import fr.acinq.lightning.utils.* +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toMilliSatoshi import fr.acinq.lightning.wire.* import kotlin.test.* @@ -36,8 +35,9 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Htlc_Add`() { - val (alice, _, _) = initMutualClose() - val (_, actions) = alice.process( + val (alice, _) = reachNormal() + val (alice1, _) = localClose(alice) + val (_, actions) = alice1.process( ChannelCommand.Htlc.Add( 1000000.msat, ByteVector32.Zeroes, @@ -47,91 +47,7 @@ class ClosingTestsCommon : LightningTestSuite() { ) ) assertEquals(1, actions.size) - assertTrue { (actions.first() as ChannelAction.ProcessCmdRes.AddFailed).error == ChannelUnavailable(alice.state.channelId) } - } - - @Test - fun `recv ChannelCommand_Htlc_Settlement_Fulfill -- nonexistent htlc`() { - val (alice, _, _) = initMutualClose() - val (_, actions) = alice.process(ChannelCommand.Htlc.Settlement.Fulfill(1, ByteVector32.Zeroes)) - assertTrue { actions.size == 1 && (actions.first() as ChannelAction.ProcessCmdRes.NotExecuted).t is UnknownHtlcId } - } - - @Test - fun `recv BITCOIN_FUNDING_SPENT -- mutual close before converging`() { - val (alice0, bob0) = reachNormal() - // alice initiates a closing with a low fee - val (alice1, aliceActions1) = alice0.process(ChannelCommand.Close.MutualClose(null, ClosingFeerates(FeeratePerKw(500.sat), FeeratePerKw(250.sat), FeeratePerKw(1000.sat)))) - val shutdown0 = aliceActions1.findOutgoingMessage() - val (bob1, bobActions1) = bob0.process(ChannelCommand.MessageReceived(shutdown0)) - assertIs(bob1.state) - val shutdown1 = bobActions1.findOutgoingMessage() - val (alice2, aliceActions2) = alice1.process(ChannelCommand.MessageReceived(shutdown1)) - assertIs(alice2.state) - val closingSigned0 = aliceActions2.findOutgoingMessage() - - // they don't converge yet, but bob has a publishable commit tx now - val (bob2, bobActions2) = bob1.process(ChannelCommand.MessageReceived(closingSigned0)) - assertIs(bob2.state) - val mutualCloseTx = bob2.state.bestUnpublishedClosingTx - assertNotNull(mutualCloseTx) - val closingSigned1 = bobActions2.findOutgoingMessage() - assertNotEquals(closingSigned0.feeSatoshis, closingSigned1.feeSatoshis) - - // let's make bob publish this closing tx - val (bob3, bobActions3) = bob2.process(ChannelCommand.MessageReceived(Error(ByteVector32.Zeroes, ""))) - assertIs(bob3.state) - assertEquals(ChannelAction.Blockchain.PublishTx(mutualCloseTx), bobActions3.filterIsInstance().first()) - assertEquals(mutualCloseTx, bob3.state.mutualClosePublished.last()) - bobActions3.find().also { - assertEquals(mutualCloseTx.tx.txid, it.txId) - assertEquals(ChannelClosingType.Mutual, it.closingType) - assertTrue(it.isSentToDefaultAddress) - } - - // actual test starts here - val (bob4, _) = bob3.process(ChannelCommand.WatchReceived(WatchEventSpent(ByteVector32.Zeroes, BITCOIN_FUNDING_SPENT, mutualCloseTx.tx))) - val (bob5, bobActions5) = bob4.process(ChannelCommand.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) - assertIs(bob5.state) - assertContains(bobActions5, ChannelAction.Storage.SetLocked(mutualCloseTx.tx.txid)) - } - - @Test - fun `recv BITCOIN_TX_CONFIRMED -- mutual close`() { - val (alice0, _, _) = initMutualClose() - val mutualCloseTx = alice0.state.mutualClosePublished.last() - - // actual test starts here - val (alice1, actions1) = alice0.process(ChannelCommand.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) - assertIs(alice1.state) - assertContains(actions1, ChannelAction.Storage.SetLocked(mutualCloseTx.tx.txid)) - } - - @Test - fun `recv BITCOIN_TX_CONFIRMED -- mutual close with external btc address`() { - val pubKey = Lightning.randomKey().publicKey() - val bobBtcAddr = computeP2PkhAddress(pubKey, TestConstants.Bob.nodeParams.chainHash) - val bobFinalScript = Script.write(Script.pay2pkh(pubKey)).toByteVector() - - val (alice1, bob1) = reachNormal() - val (_, bob2, aliceClosingSigned) = mutualCloseBob(alice1, bob1, scriptPubKey = bobFinalScript) - - val (bob3, bobActions3) = bob2.process(ChannelCommand.MessageReceived(aliceClosingSigned)) - assertIs(bob3.state) - val bobClosingSigned = bobActions3.findOutgoingMessageOpt() - assertNotNull(bobClosingSigned) - val mutualCloseTx = bob3.state.mutualClosePublished.last() - bobActions3.find().also { - assertEquals(mutualCloseTx.tx.txid, it.txId) - assertEquals(ChannelClosingType.Mutual, it.closingType) - assertFalse(it.isSentToDefaultAddress) - assertEquals(bobBtcAddr, it.address) - } - - // actual test starts here - val (bob4, bobActions4) = bob3.process(ChannelCommand.WatchReceived(WatchEventConfirmed(ByteVector32.Zeroes, BITCOIN_TX_CONFIRMED(mutualCloseTx.tx), 0, 0, mutualCloseTx.tx))) - assertIs(bob4.state) - assertContains(bobActions4, ChannelAction.Storage.SetLocked(mutualCloseTx.tx.txid)) + assertEquals((actions.first() as ChannelAction.ProcessCmdRes.AddFailed).error, ChannelUnavailable(alice.state.channelId)) } @Test @@ -539,15 +455,13 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv BITCOIN_FUNDING_SPENT -- remote commit`() { - val (alice, _, bobCommitTxs) = initMutualClose(withPayments = true) - // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state - val bobCommitTx = bobCommitTxs.last().commitTx.tx + val (alice0, bob0) = reachNormal() + val bobCommitTx = bob0.commitments.latest.localCommit.publishableTxs.commitTx.tx assertEquals(4, bobCommitTx.txOut.size) // main outputs and anchors - val (aliceClosing, remoteCommitPublished) = remoteClose(bobCommitTx, alice) + val (_, remoteCommitPublished) = remoteClose(bobCommitTx, alice0) assertNotNull(remoteCommitPublished.claimMainOutputTx) assertTrue(remoteCommitPublished.claimHtlcSuccessTxs().isEmpty()) assertTrue(remoteCommitPublished.claimHtlcTimeoutTxs().isEmpty()) - assertEquals(alice.state, aliceClosing.state.copy(remoteCommitPublished = null)) } @Test @@ -1683,26 +1597,17 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv ChannelReestablish`() { - val (alice0, bob0, _) = initMutualClose() - val bobCurrentPerCommitmentPoint = bob0.commitments.params.localParams.channelKeys(bob0.ctx.keyManager).commitmentPoint(bob0.commitments.localCommitIndex) - val channelReestablish = ChannelReestablish(bob0.channelId, 42, 42, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint) - val (alice1, actions1) = alice0.process(ChannelCommand.MessageReceived(channelReestablish)) - assertIs(alice1.state) - assertNull(alice1.state.localCommitPublished) - assertNull(alice1.state.remoteCommitPublished) - assertTrue(alice1.state.mutualClosePublished.isNotEmpty()) - val error = actions1.hasOutgoingMessage() - assertEquals(error.toAscii(), FundingTxSpent(alice0.channelId, alice0.state.mutualClosePublished.first().tx.txid).message) - } - - @Test - fun `recv ChannelCommand_Close_MutualClose`() { - val (alice0, _, _) = initMutualClose() - val cmdClose = ChannelCommand.Close.MutualClose(null, null) - val (_, actions) = alice0.process(cmdClose) - val commandError = actions.filterIsInstance().first() - assertEquals(cmdClose, commandError.cmd) - assertEquals(ClosingAlreadyInProgress(alice0.channelId), commandError.t) + val (alice, bob) = reachNormal() + val (alice1, lcp) = localClose(alice) + val bobCurrentPerCommitmentPoint = bob.commitments.params.localParams.channelKeys(bob.ctx.keyManager).commitmentPoint(bob.commitments.localCommitIndex) + val channelReestablish = ChannelReestablish(bob.channelId, 42, 42, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint) + val (alice2, actions2) = alice1.process(ChannelCommand.MessageReceived(channelReestablish)) + assertIs(alice2.state) + assertNotNull(alice2.state.localCommitPublished) + assertEquals(lcp, alice2.state.localCommitPublished) + assertNull(alice2.state.remoteCommitPublished) + val error = actions2.hasOutgoingMessage() + assertEquals(error.toAscii(), FundingTxSpent(alice.channelId, lcp.commitTx.txid).message) } @Test @@ -1726,52 +1631,13 @@ class ClosingTestsCommon : LightningTestSuite() { @Test fun `recv Disconnected`() { - val (alice0, _, _) = initMutualClose() - val (alice1, _) = alice0.process(ChannelCommand.Disconnected) - assertTrue { alice1.state is Closing } + val (alice, _) = reachNormal() + val (alice1, _) = localClose(alice) + val (alice2, _) = alice1.process(ChannelCommand.Disconnected) + assertIs(alice2.state) } companion object { - fun initMutualClose(withPayments: Boolean = false): Triple, LNChannel, List> { - val (aliceInit, bobInit) = reachNormal() - var mutableAlice: LNChannel = aliceInit - var mutableBob: LNChannel = bobInit - - val bobCommitTxs = if (!withPayments) { - listOf() - } else { - listOf(100_000_000.msat, 200_000_000.msat, 300_000_000.msat).map { amount -> - val (nodes, r, htlc) = addHtlc(amount, payer = mutableAlice, payee = mutableBob) - mutableAlice = nodes.first - mutableBob = nodes.second - - with(crossSign(mutableAlice, mutableBob)) { - mutableAlice = first - mutableBob = second - } - - val bobCommitTx1 = mutableBob.commitments.latest.localCommit.publishableTxs - - with(fulfillHtlc(htlc.id, r, payer = mutableAlice, payee = mutableBob)) { - mutableAlice = first - mutableBob = second - } - with(crossSign(mutableBob, mutableAlice)) { - mutableBob = first - mutableAlice = second - } - - val bobCommitTx2 = mutableBob.commitments.latest.localCommit.publishableTxs - listOf(bobCommitTx1, bobCommitTx2) - }.flatten() - } - - 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) - } - data class RevokedCloseFixture(val alice: LNChannel, val bob: LNChannel, val bobRevokedTxs: List, val htlcsAlice: List, val htlcsBob: List) fun prepareRevokedClose(): RevokedCloseFixture { 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 ac2d1f30c..782cb309e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NegotiatingTestsCommon.kt @@ -2,15 +2,18 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* import fr.acinq.lightning.Feature +import fr.acinq.lightning.Lightning.randomBytes64 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.addHtlc +import fr.acinq.lightning.channel.TestsHelper.crossSign +import fr.acinq.lightning.channel.TestsHelper.fulfillHtlc import fr.acinq.lightning.channel.TestsHelper.makeCmdAdd -import fr.acinq.lightning.channel.TestsHelper.mutualCloseAlice -import fr.acinq.lightning.channel.TestsHelper.mutualCloseBob import fr.acinq.lightning.channel.TestsHelper.reachNormal +import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat @@ -31,399 +34,293 @@ class NegotiatingTestsCommon : LightningTestSuite() { actions1.hasCommandError() } - private fun testClosingSignedDifferentFees(alice: LNChannel, bob: LNChannel, 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 initiator 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.state.closingTxProposed.size, 1) - assertEquals(alice2.state.closingTxProposed.last().size, 1) - assertNull(alice2.state.bestUnpublishedClosingTx) - - // bob answers with a counter proposition in alice's fee range - val (bob3, bobActions3) = bob2.process(ChannelCommand.MessageReceived(aliceCloseSig1)) - assertIs>(bob3) - 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.state.bestUnpublishedClosingTx) - - // alice accepts this proposition - val (alice3, aliceActions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig1)) - assertIs>(alice3) - val mutualCloseTx = aliceActions3.findPublishTxs().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.process(ChannelCommand.MessageReceived(aliceCloseSig2)) - assertIs>(bob4) - bobActions4.hasPublishTx(mutualCloseTx) - assertEquals(bobActions4.findWatch().txId, mutualCloseTx.txid) - assertEquals(alice3.state.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) - assertEquals(bob4.state.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) - } - @Test - fun `recv ClosingSigned -- theirCloseFee != ourCloseFee`() { - val (alice, bob) = reachNormal() - testClosingSignedDifferentFees(alice, bob) + fun `recv ClosingComplete -- both outputs`() { + val (alice, bob, closingCompleteAlice, closingCompleteBob) = init() + + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingCompleteBob)) + assertEquals(4, actionsAlice1.size) + actionsAlice1.has() + val closingTxBob = actionsAlice1.findPublishTxs().first() + actionsAlice1.hasWatchConfirmed(closingTxBob.txid) + val closingSigAlice = actionsAlice1.findOutgoingMessage() + + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingCompleteAlice)) + assertEquals(4, actionsBob1.size) + actionsBob1.has() + val closingTxAlice = actionsBob1.findPublishTxs().first() + actionsBob1.hasWatchConfirmed(closingTxAlice.txid) + val closingSigBob = actionsBob1.findOutgoingMessage() + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(closingSigBob)) + assertIs(alice2.state) + assertEquals(3, actionsAlice2.size) + actionsAlice2.has() + assertTrue(actionsAlice2.findPublishTxs().contains(closingTxAlice)) + actionsAlice2.hasWatchConfirmed(closingTxAlice.txid) + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(closingSigAlice)) + assertIs(bob2.state) + assertEquals(3, actionsBob2.size) + actionsBob2.has() + assertTrue(actionsBob2.findPublishTxs().contains(closingTxBob)) + actionsBob2.hasWatchConfirmed(closingTxBob.txid) } @Test - fun `recv ClosingSigned -- theirCloseFee != ourCloseFee + bob starts closing`() { - val (alice, bob) = reachNormal() - testClosingSignedDifferentFees(alice, bob, bobInitiates = true) + fun `recv ClosingComplete -- both outputs -- external address`() { + val closingScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).toByteVector() + val (alice, bob, closingComplete, _) = init(closingScript = closingScript) + + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs(bob1.state) + val closingTx = actionsBob1.findPublishTxs().first() + assertTrue(closingTx.txOut.any { it.publicKeyScript == closingScript }) + val closingSig = actionsBob1.findOutgoingMessage() + + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingSig)) + assertIs(alice1.state) + assertTrue(actionsAlice1.findPublishTxs().contains(closingTx)) } @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.process(ChannelCommand.MessageReceived(aliceCloseSig)) - assertIs>(bob3) - val bobCloseSig = actions.findOutgoingMessage() - assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.feeSatoshis) + fun `recv ClosingComplete -- single output`() { + val (alice, bob) = reachNormal(bobFundingAmount = 0.sat, alicePushAmount = 0.msat) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(null, null)) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(shutdownAlice)) + val shutdownBob = actionsBob1.findOutgoingMessage() + assertNull(actionsBob1.findOutgoingMessageOpt()) // Bob cannot pay mutual close fees. + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) + val closingCompleteAlice = actionsAlice2.findOutgoingMessage() + assertNull(closingCompleteAlice.closerAndCloseeSig) + assertNotNull(closingCompleteAlice.closerNoCloseeSig) + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(closingCompleteAlice)) + assertIs(bob2.state) + val closingTxAlice = actionsBob2.findPublishTxs().first() + assertEquals(1, closingTxAlice.txOut.size) + val closingSigBob = actionsBob2.findOutgoingMessage() + assertNotNull(closingSigBob.closerNoCloseeSig) + + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.MessageReceived(closingSigBob)) + assertIs(alice3.state) + assertTrue(actionsAlice3.findPublishTxs().contains(closingTxAlice)) + actionsAlice3.hasWatchConfirmed(closingTxAlice.txid) } @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.process(ChannelCommand.MessageReceived(aliceCloseSig)) - val bobCloseSig = actions.findOutgoingMessage() - assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.tlvStream.get()!!.max) - } - - private fun testClosingSignedSameFees(alice: LNChannel, bob: LNChannel, 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 initiator 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.process(ChannelCommand.MessageReceived(aliceCloseSig1)) - assertIs>(bob3) - val bobCloseSig1 = bobActions3.findOutgoingMessage() - assertNotNull(bobCloseSig1.tlvStream.get()) - assertEquals(aliceCloseSig1.feeSatoshis, bobCloseSig1.feeSatoshis) - val mutualCloseTx = bobActions3.findPublishTxs().first() - assertEquals(mutualCloseTx.txOut.size, 2) // NB: anchors are removed from the closing tx - - val (alice3, aliceActions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig1)) - assertIs>(alice3) - aliceActions3.hasPublishTx(mutualCloseTx) + fun `recv ClosingComplete -- with concurrent shutdown`() { + val (alice, bob, oldClosingComplete, _) = init() + + // Bob updates his closing script before receiving Alice's closing_complete. + val closingScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + val (bob1, actionsBob1) = bob.process(ChannelCommand.Close.MutualClose(closingScript, null)) + assertEquals(3, actionsBob1.size) + actionsBob1.has() + val shutdown = actionsBob1.findOutgoingMessage() + actionsBob1.findOutgoingMessage() + + // Bob ignores Alice's obsolete closing_complete. + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(oldClosingComplete)) + assertTrue(actionsBob2.isEmpty()) + + // Alice sends a new closing_complete when receiving Bob's shutdown. + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(shutdown)) + assertEquals(2, actionsAlice1.size) + actionsAlice1.has() + val closingComplete = actionsAlice1.findOutgoingMessage() + + val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs(bob3.state) + assertEquals(4, actionsBob3.size) + actionsBob3.has() + val closingTx = actionsBob3.findPublishTxs().first() + assertTrue(closingTx.txOut.any { it.publicKeyScript == closingScript }) + actionsBob3.hasWatchConfirmed(closingTx.txid) + val closingSig = actionsBob3.findOutgoingMessage() + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(closingSig)) + assertIs(alice2.state) + assertEquals(3, actionsAlice2.size) + actionsAlice2.has() + assertEquals(closingTx, actionsAlice2.findPublishTxs().first()) + actionsAlice2.hasWatchConfirmed(closingTx.txid) } @Test - fun `recv ClosingSigned -- theirCloseFee == ourCloseFee`() { - val (alice, bob) = reachNormal() - testClosingSignedSameFees(alice, bob) + fun `recv ClosingComplete -- missing closee output`() { + val (_, bob, closingComplete, _) = init() + + // Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's + // closing_complete instead of sending back his closing_sig. + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete.copy(tlvStream = TlvStream(ClosingCompleteTlv.CloserNoClosee(closingComplete.closerNoCloseeSig!!))))) + assertIs(bob1.state) + assertTrue(actionsBob1.isEmpty()) + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs(bob2.state) + val closingTxAlice = actionsBob2.findPublishTxs().first() + assertTrue(closingTxAlice.txOut.size == 2) + actionsBob2.findOutgoingMessage() } @Test - fun `recv ClosingSigned -- theirCloseFee == ourCloseFee + bob starts closing`() { - val (alice, bob) = reachNormal() - testClosingSignedSameFees(alice, bob, bobInitiates = true) - } + fun `recv ClosingSig -- missing signature`() { + val (alice, bob, closingComplete, _) = init() - @Test - fun `override on-chain fee estimator -- initiator`() { - 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.process(ChannelCommand.MessageReceived(aliceCloseSig)) - val bobCloseSig = bobActions3.findOutgoingMessage() - assertEquals(bobCloseSig.feeSatoshis, 2022.sat) - - // alice accepts this proposition - val (alice3, aliceActions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig)) - assertIs>(alice3) - val mutualCloseTx = aliceActions3.findPublishTxs().first() - val aliceCloseSig2 = aliceActions3.findOutgoingMessage() - assertEquals(aliceCloseSig2.feeSatoshis, 2022.sat) - - val (bob4, bobActions4) = bob3.process(ChannelCommand.MessageReceived(aliceCloseSig2)) - assertIs>(bob4) - bobActions4.hasPublishTx(mutualCloseTx) - } + val (_, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + val closingSig = actionsBob1.findOutgoingMessage() - @Test - fun `override on-chain fee estimator -- non-initiator`() { - val (alice, bob) = reachNormal() - val alice1 = alice.updateFeerate(FeeratePerKw(10_000.sat)) - val bob1 = bob.updateFeerate(FeeratePerKw(10_000.sat)) - - // alice is initiator, 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.process(ChannelCommand.MessageReceived(aliceCloseSig)) - assertIs>(bob3) - val mutualCloseTx = bobActions3.findPublishTxs().first() - val bobCloseSig = bobActions3.findOutgoingMessage() - assertEquals(bobCloseSig.feeSatoshis, aliceCloseSig.feeSatoshis) - - // alice accepts this proposition - val (alice3, aliceActions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig)) - assertIs>(alice3) - aliceActions3.hasPublishTx(mutualCloseTx) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingSig.copy(tlvStream = TlvStream.empty()))) + assertIs(alice1.state) + assertTrue(actionsAlice1.isEmpty()) } @Test - fun `recv ClosingSigned -- nothing at stake`() { - val (alice, bob) = reachNormal(bobFundingAmount = 0.sat, alicePushAmount = 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.process(ChannelCommand.MessageReceived(aliceCloseSig)) - assertIs>(bob3) - val mutualCloseTx = bobActions3.findPublishTxs().first() - assertEquals(bob3.state.mutualClosePublished.map { it.tx }, listOf(mutualCloseTx)) - assertEquals(bobActions3.findWatches().map { it.event }, listOf(BITCOIN_TX_CONFIRMED(mutualCloseTx))) - } + fun `recv ClosingSig -- invalid signature`() { + val (alice, bob, closingComplete, _) = init() - @Test - fun `recv ClosingSigned -- other side ignores our fee range + initiator`() { - 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.state.closingTxProposed.last().size, 1) - assertNull(alice2.state.bestUnpublishedClosingTx) - - // bob makes a proposal outside our fee range - val (_, bobCloseSig1) = makeLegacyClosingSigned(alice2, bob2, 2_500.sat) - val (alice3, actions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig1)) - assertIs>(alice3) - val aliceCloseSig2 = actions3.findOutgoingMessage() - assertTrue(aliceCloseSig1.feeSatoshis < aliceCloseSig2.feeSatoshis) - assertTrue(aliceCloseSig2.feeSatoshis < 1600.sat) - assertEquals(alice3.state.closingTxProposed.last().size, 2) - assertNotNull(alice3.state.bestUnpublishedClosingTx) - - val (_, bobCloseSig2) = makeLegacyClosingSigned(alice2, bob2, 2_000.sat) - val (alice4, actions4) = alice3.process(ChannelCommand.MessageReceived(bobCloseSig2)) - assertIs>(alice4) - val aliceCloseSig3 = actions4.findOutgoingMessage() - assertTrue(aliceCloseSig2.feeSatoshis < aliceCloseSig3.feeSatoshis) - assertTrue(aliceCloseSig3.feeSatoshis < 1800.sat) - assertEquals(alice4.state.closingTxProposed.last().size, 3) - assertNotNull(alice4.state.bestUnpublishedClosingTx) - - val (_, bobCloseSig3) = makeLegacyClosingSigned(alice2, bob2, 1_800.sat) - val (alice5, actions5) = alice4.process(ChannelCommand.MessageReceived(bobCloseSig3)) - assertIs>(alice5) - val aliceCloseSig4 = actions5.findOutgoingMessage() - assertTrue(aliceCloseSig3.feeSatoshis < aliceCloseSig4.feeSatoshis) - assertTrue(aliceCloseSig4.feeSatoshis < 1800.sat) - assertEquals(alice5.state.closingTxProposed.last().size, 4) - assertNotNull(alice5.state.bestUnpublishedClosingTx) - - val (_, bobCloseSig4) = makeLegacyClosingSigned(alice2, bob2, aliceCloseSig4.feeSatoshis) - val (alice6, actions6) = alice5.process(ChannelCommand.MessageReceived(bobCloseSig4)) - assertIs>(alice6) - val mutualCloseTx = actions6.findPublishTxs().first() - assertEquals(alice6.state.mutualClosePublished.size, 1) - assertEquals(mutualCloseTx, alice6.state.mutualClosePublished.first().tx) + val (_, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + val closingSig = actionsBob1.findOutgoingMessage() + + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingSig.copy(tlvStream = TlvStream(ClosingSigTlv.CloserAndClosee(randomBytes64()))))) + assertIs(alice1.state) + assertTrue(actionsAlice1.isEmpty()) } @Test - fun `recv ClosingSigned -- other side ignores our fee range + non-initiator`() { - 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.process(ChannelCommand.MessageReceived(aliceCloseSig1)) - assertIs>(bob3) - val bobCloseSig1 = actions3.findOutgoingMessage() - assertTrue(3000.sat < bobCloseSig1.feeSatoshis) - assertEquals(bob3.state.closingTxProposed.last().size, 1) - assertNotNull(bob3.state.bestUnpublishedClosingTx) - - val (aliceCloseSig2, _) = makeLegacyClosingSigned(alice2, bob2, 750.sat) - val (bob4, actions4) = bob3.process(ChannelCommand.MessageReceived(aliceCloseSig2)) - assertIs>(bob4) - val bobCloseSig2 = actions4.findOutgoingMessage() - assertTrue(2000.sat < bobCloseSig2.feeSatoshis) - assertEquals(bob4.state.closingTxProposed.last().size, 2) - assertNotNull(bob4.state.bestUnpublishedClosingTx) - - val (aliceCloseSig3, _) = makeLegacyClosingSigned(alice2, bob2, 1000.sat) - val (bob5, actions5) = bob4.process(ChannelCommand.MessageReceived(aliceCloseSig3)) - assertIs>(bob5) - val bobCloseSig3 = actions5.findOutgoingMessage() - assertTrue(1500.sat < bobCloseSig3.feeSatoshis) - assertEquals(bob5.state.closingTxProposed.last().size, 3) - assertNotNull(bob5.state.bestUnpublishedClosingTx) - - val (aliceCloseSig4, _) = makeLegacyClosingSigned(alice2, bob2, 1300.sat) - val (bob6, actions6) = bob5.process(ChannelCommand.MessageReceived(aliceCloseSig4)) - assertIs>(bob6) - val bobCloseSig4 = actions6.findOutgoingMessage() - assertTrue(1300.sat < bobCloseSig4.feeSatoshis) - assertEquals(bob6.state.closingTxProposed.last().size, 4) - assertNotNull(bob6.state.bestUnpublishedClosingTx) - - val (aliceCloseSig5, _) = makeLegacyClosingSigned(alice2, bob2, bobCloseSig4.feeSatoshis) - val (bob7, actions7) = bob6.process(ChannelCommand.MessageReceived(aliceCloseSig5)) - assertIs>(bob7) - val mutualCloseTx = actions7.findPublishTxs().first() - assertEquals(bob7.state.mutualClosePublished.size, 1) - assertEquals(mutualCloseTx, bob7.state.mutualClosePublished.first().tx) + fun `recv ClosingComplete and ClosingSig with encrypted channel data`() { + val (alice, bob, closingCompleteAlice, closingCompleteBob) = init() + assertTrue(alice.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupProvider)) + assertTrue(bob.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupClient)) + assertTrue(closingCompleteAlice.channelData.isEmpty()) + assertFalse(closingCompleteBob.channelData.isEmpty()) + val (_, actions1) = bob.process(ChannelCommand.MessageReceived(closingCompleteAlice)) + val closingSigBob = actions1.hasOutgoingMessage() + assertFalse(closingSigBob.channelData.isEmpty()) } @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) - assertIs>(alice2) - var mutableAlice: LNChannel = alice2 - 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.process(ChannelCommand.MessageReceived(bobClosing)) - aliceCloseSig = actions.findOutgoingMessage() - assertIs>(aliceNew) - mutableAlice = aliceNew + fun `recv BITCOIN_TX_CONFIRMED -- signed mutual close`() { + val (alice, bob, closingComplete, _) = init() + + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + val closingTx = actionsBob1.findPublishTxs().first() + val closingSig = actionsBob1.findOutgoingMessage() + + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingSig)) + assertTrue(actionsAlice1.findPublishTxs().contains(closingTx)) + actionsAlice1.hasWatchConfirmed(closingTx.txid) + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_TX_CONFIRMED(closingTx), 0, 0, closingTx))) + assertIs(alice2.state) + assertEquals(3, actionsAlice2.size) + actionsAlice2.has() + actionsAlice2.find().also { + assertEquals(closingTx.txid, it.txId) + assertEquals(ChannelClosingType.Mutual, it.closingType) + assertTrue(it.miningFees > 0.sat) + assertEquals(800_000.sat, it.amount + it.miningFees) } - - assertIs>(mutableAlice) - assertEquals(mutableAlice.state.mutualClosePublished.size, 1) + actionsAlice2.find().also { assertEquals(closingTx.txid, it.txId) } + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob.channelId, BITCOIN_TX_CONFIRMED(closingTx), 0, 0, closingTx))) + assertIs(bob2.state) + assertEquals(3, actionsBob2.size) + actionsBob2.has() + actionsBob2.find().also { + assertEquals(closingTx.txid, it.txId) + assertEquals(ChannelClosingType.Mutual, it.closingType) + assertEquals(200_000.sat, it.amount) + } + actionsBob2.find().also { assertEquals(closingTx.txid, it.txId) } } @Test - fun `recv ClosingSigned -- invalid signature`() { - val (_, bob, aliceCloseSig) = init() - val (bob1, actions) = bob.process(ChannelCommand.MessageReceived(aliceCloseSig.copy(feeSatoshis = 99_000.sat))) - assertIs>(bob1) - actions.hasOutgoingMessage() - actions.hasWatch() - actions.findPublishTxs().contains(bob.commitments.latest.localCommit.publishableTxs.commitTx.tx) + fun `recv BITCOIN_TX_CONFIRMED -- proposed mutual close`() { + val (alice, bob, closingComplete, _) = init() + + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + val closingTx = actionsBob1.findPublishTxs().first() + actionsBob1.findOutgoingMessage() + + val (alice1, actionsAlice1) = alice.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_TX_CONFIRMED(closingTx), 0, 0, closingTx))) + assertIs(alice1.state) + assertEquals(3, actionsAlice1.size) + actionsAlice1.has() + actionsAlice1.find().also { + assertEquals(closingTx.txid, it.txId) + assertEquals(ChannelClosingType.Mutual, it.closingType) + assertTrue(it.miningFees > 0.sat) + assertEquals(800_000.sat, it.amount + it.miningFees) + } + actionsAlice1.find().also { assertEquals(closingTx.txid, it.txId) } + + val (bob2, actionsBob2) = bob1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob.channelId, BITCOIN_TX_CONFIRMED(closingTx), 0, 0, closingTx))) + assertIs(bob2.state) + assertEquals(3, actionsBob2.size) + actionsBob2.has() + actionsBob2.find().also { + assertEquals(closingTx.txid, it.txId) + assertEquals(ChannelClosingType.Mutual, it.closingType) + assertEquals(200_000.sat, it.amount) + } + actionsBob2.find().also { assertEquals(closingTx.txid, it.txId) } } @Test - fun `recv ClosingSigned with encrypted channel data`() { - val (alice, bob, aliceCloseSig) = init() - assertTrue(alice.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupProvider)) - assertTrue(bob.commitments.params.localParams.features.hasFeature(Feature.ChannelBackupClient)) - assertTrue(aliceCloseSig.channelData.isEmpty()) - val (_, actions1) = bob.process(ChannelCommand.MessageReceived(aliceCloseSig)) - val bobCloseSig = actions1.hasOutgoingMessage() - assertFalse(bobCloseSig.channelData.isEmpty()) + fun `recv BITCOIN_FUNDING_SPENT -- their commit`() { + val (alice, bob, closingComplete, _) = init() + val (alice1, bob1) = signClosingTxAlice(alice, bob, closingComplete) + + val bobCommitTx = bob1.commitments.latest.localCommit.publishableTxs.commitTx.tx + val (alice2, actions2) = alice1.process(ChannelCommand.WatchReceived(WatchEventSpent(alice.channelId, BITCOIN_FUNDING_SPENT, bobCommitTx))) + assertIs>(alice2) + assertEquals(5, actions2.size) + assertNotNull(alice2.state.remoteCommitPublished) + assertTrue(alice2.state.mutualClosePublished.isNotEmpty()) + actions2.has() + val claimMain = actions2.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimRemoteDelayedOutputTx) + actions2.hasWatchConfirmed(bobCommitTx.txid) + actions2.hasWatchConfirmed(claimMain.txid) + actions2.has() } @Test - fun `recv BITCOIN_FUNDING_SPENT -- counterparty's mutual close`() { - // NB: we're not the initiator here - val (bob, alice, fundingTx) = 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.process(ChannelCommand.Close.MutualClose(finalScript, null)) - val shutdownA = actions1.findOutgoingMessage() - - // Bob replies with Shutdown + ClosingSigned - val (bob1, actions2) = bob.process(ChannelCommand.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.process(ChannelCommand.MessageReceived(shutdownB)) - val (alice3, actions4) = alice2.process(ChannelCommand.MessageReceived(closingSignedB)) - assertIs>(alice3) - val closingTxA = actions4.filterIsInstance().first().tx - val closingSignedA = actions4.findOutgoingMessage() - val watch = actions4.findWatch() - assertEquals(watch.txId, closingTxA.txid) - - 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.process(ChannelCommand.MessageReceived(closingSignedA)) - assertIs>(bob2) - 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.process(ChannelCommand.WatchReceived(WatchEventSpent(alice3.channelId, BITCOIN_FUNDING_SPENT, closingTxB))) - assertIs>(alice4) - - val (alice5, _) = alice4.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice3.channelId, BITCOIN_TX_CONFIRMED(closingTxA), 144, 0, closingTxA))) - assertIs>(alice5) - } + fun `recv BITCOIN_FUNDING_SPENT -- revoked tx`() { + val (alice, bob, revokedCommit) = run { + val (alice0, bob0) = reachNormal() + val revokedCommit = bob0.commitments.latest.localCommit.publishableTxs.commitTx.tx + val (nodes1, r, htlc) = addHtlc(50_000_000.msat, alice0, bob0) + val (alice2, bob2) = crossSign(nodes1.first, nodes1.second) + val (alice3, bob3) = fulfillHtlc(htlc.id, r, alice2, bob2) + val (bob4, alice4) = crossSign(bob3, alice3) + Triple(alice4, bob4, revokedCommit) + } - @Test - 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) - - val (bob3, bobActions3) = bob2.process(ChannelCommand.MessageReceived(aliceCloseSig1)) - assertIs>(bob3) - bobActions3.findOutgoingMessage() - val firstMutualCloseTx = bob3.state.bestUnpublishedClosingTx - assertNotNull(firstMutualCloseTx) - - val (_, bobCloseSig1) = makeLegacyClosingSigned(alice2, bob2, 3_000.sat) - assertNotEquals(bobCloseSig1.feeSatoshis, aliceCloseSig1.feeSatoshis) - val (alice3, aliceActions3) = alice2.process(ChannelCommand.MessageReceived(bobCloseSig1)) - assertIs>(alice3) - val aliceCloseSig2 = aliceActions3.findOutgoingMessage() - assertNotEquals(aliceCloseSig2.feeSatoshis, bobCloseSig1.feeSatoshis) - val latestMutualCloseTx = alice3.state.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.process(ChannelCommand.WatchReceived(WatchEventSpent(alice3.channelId, BITCOIN_FUNDING_SPENT, firstMutualCloseTx.tx))) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(null, null)) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(shutdownAlice)) + assertIs>(bob1) + val shutdownBob = actionsBob1.findOutgoingMessage() + actionsBob1.findOutgoingMessage() + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) + assertIs>(alice2) + val closingComplete = actionsAlice2.findOutgoingMessage() + val (alice3, _) = signClosingTxAlice(alice2, bob1, closingComplete) + + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.WatchReceived(WatchEventSpent(alice.channelId, BITCOIN_FUNDING_SPENT, revokedCommit))) assertIs>(alice4) - aliceActions4.has() - aliceActions4.hasPublishTx(firstMutualCloseTx.tx) - assertEquals(aliceActions4.hasWatch().txId, firstMutualCloseTx.tx.txid) + assertTrue(alice4.state.revokedCommitPublished.isNotEmpty()) + assertTrue(alice4.state.mutualClosePublished.isNotEmpty()) + actionsAlice4.has() + val claimMain = actionsAlice4.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.ClaimRemoteDelayedOutputTx) + actionsAlice4.hasPublishTx(ChannelAction.Blockchain.PublishTx.Type.MainPenaltyTx) + actionsAlice4.hasWatchConfirmed(revokedCommit.txid) + actionsAlice4.hasWatchConfirmed(claimMain.txid) + actionsAlice4.has() } @Test @@ -444,46 +341,42 @@ class NegotiatingTestsCommon : LightningTestSuite() { } companion object { + data class Fixture(val alice: LNChannel, val bob: LNChannel, val closingCompleteAlice: ClosingComplete, val closingCompleteBob: ClosingComplete) + fun init( channelType: ChannelType.SupportedChannelType = ChannelType.SupportedChannelType.AnchorOutputs, - alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount - ): Triple, LNChannel, ClosingSigned> { - val (alice, bob) = reachNormal(channelType = channelType, alicePushAmount = alicePushAmount) - return mutualCloseAlice(alice, bob) - } - - private fun makeLegacyClosingSigned(alice: LNChannel, bob: LNChannel, closingFee: Satoshi): Pair { - val aliceScript = alice.state.localShutdown.scriptPubKey.toByteArray() - val bobScript = bob.state.localShutdown.scriptPubKey.toByteArray() - val aliceKeys = alice.ctx.keyManager.channelKeys(alice.commitments.params.localParams.fundingKeyPath) - val bobKeys = bob.ctx.keyManager.channelKeys(bob.commitments.params.localParams.fundingKeyPath) - val (_, aliceClosingSigned) = Helpers.Closing.makeClosingTx(aliceKeys, alice.commitments.latest, aliceScript, bobScript, ClosingFees(closingFee, closingFee, closingFee)) - val (_, bobClosingSigned) = Helpers.Closing.makeClosingTx(bobKeys, bob.commitments.latest, bobScript, aliceScript, ClosingFees(closingFee, closingFee, closingFee)) - return Pair(aliceClosingSigned.copy(tlvStream = TlvStream.empty()), bobClosingSigned.copy(tlvStream = TlvStream.empty())) + aliceFundingAmount: Satoshi = TestConstants.aliceFundingAmount, + bobFundingAmount: Satoshi = TestConstants.bobFundingAmount, + alicePushAmount: MilliSatoshi = TestConstants.alicePushAmount, + closingFeerate: FeeratePerKw? = null, + closingScript: ByteVector? = null, + ): Fixture { + val (alice, bob) = reachNormal(channelType = channelType, aliceFundingAmount = aliceFundingAmount, bobFundingAmount = bobFundingAmount, alicePushAmount = alicePushAmount) + val (alice1, actionsAlice1) = alice.process(ChannelCommand.Close.MutualClose(closingScript, closingFeerate)) + val shutdownAlice = actionsAlice1.findOutgoingMessage() + assertNull(actionsAlice1.findOutgoingMessageOpt()) + + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(shutdownAlice)) + assertIs>(bob1) + val shutdownBob = actionsBob1.findOutgoingMessage() + val closingCompleteBob = actionsBob1.findOutgoingMessage() + + val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) + assertIs>(alice2) + val closingCompleteAlice = actionsAlice2.findOutgoingMessage() + + return Fixture(alice2, bob1, closingCompleteAlice, closingCompleteBob) } - tailrec fun converge(a: LNChannel, b: LNChannel, aliceCloseSig: ClosingSigned?): Pair, LNChannel>? { - return when { - a.state !is ChannelStateWithCommitments || b.state !is ChannelStateWithCommitments -> null - a.state is Closing && b.state is Closing -> Pair(LNChannel(a.ctx, a.state), LNChannel(b.ctx, b.state)) - aliceCloseSig != null -> { - val (b1, actions) = b.process(ChannelCommand.MessageReceived(aliceCloseSig)) - val bobCloseSig = actions.findOutgoingMessageOpt() - if (bobCloseSig != null) { - val (a1, actions2) = a.process(ChannelCommand.MessageReceived(bobCloseSig)) - return converge(a1, b1, actions2.findOutgoingMessageOpt()) - } - val bobClosingTx = actions.filterIsInstance().map { it.tx }.firstOrNull() - if (bobClosingTx != null && bobClosingTx.txIn[0].outPoint == a.commitments.latest.localCommit.publishableTxs.commitTx.input.outPoint && a.state !is Closing) { - // Bob just spent the funding tx - val (a1, actions2) = a.process(ChannelCommand.WatchReceived(WatchEventSpent(a.channelId, BITCOIN_FUNDING_SPENT, bobClosingTx))) - actions2.find().also { assertEquals(bobClosingTx.txid, it.txId) } - return converge(a1, b1, actions2.findOutgoingMessageOpt()) - } - converge(a, b1, null) - } - else -> null - } + fun signClosingTxAlice(alice: LNChannel, bob: LNChannel, closingComplete: ClosingComplete): Pair, LNChannel> { + val (bob1, actionsBob1) = bob.process(ChannelCommand.MessageReceived(closingComplete)) + assertIs>(bob1) + val closingTx = actionsBob1.findPublishTxs().first() + val closingSig = actionsBob1.findOutgoingMessage() + val (alice1, actionsAlice1) = alice.process(ChannelCommand.MessageReceived(closingSig)) + assertIs>(alice1) + assertTrue(actionsAlice1.findPublishTxs().contains(closingTx)) + return Pair(alice1, bob1) } } 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 0e8dbb04e..76862a3af 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/NormalTestsCommon.kt @@ -1,8 +1,11 @@ package fr.acinq.lightning.channel.states import fr.acinq.bitcoin.* -import fr.acinq.lightning.* +import fr.acinq.lightning.CltvExpiry +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Feature import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.ShortChannelId import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* @@ -1526,21 +1529,9 @@ class NormalTestsCommon : LightningTestSuite() { actions1.hasCommandError() } - @Test - fun `recv ChannelCommand_Close_MutualClose -- with unsupported native segwit script`() { - val (alice, _) = reachNormal() - assertNull(alice.state.localShutdown) - val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) - assertIs>(alice1) - actions1.hasCommandError() - } - @Test fun `recv ChannelCommand_Close_MutualClose -- with native segwit script`() { - val (alice, _) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (alice, _) = reachNormal() assertNull(alice.state.localShutdown) val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(ByteVector("51050102030405"), null)) actions1.hasOutgoingMessage() @@ -1603,7 +1594,7 @@ class NormalTestsCommon : LightningTestSuite() { val (alice1, actions1) = alice.process(ChannelCommand.MessageReceived(Shutdown(alice.channelId, bob.commitments.params.localParams.defaultFinalScriptPubKey))) assertIs>(alice1) actions1.hasOutgoingMessage() - actions1.hasOutgoingMessage() + actions1.hasOutgoingMessage() } @Test @@ -1665,13 +1656,13 @@ class NormalTestsCommon : LightningTestSuite() { val (alice5, aliceActions5) = alice4.process(ChannelCommand.MessageReceived(sigBob)) assertIs>(alice5) val revAlice = aliceActions5.hasOutgoingMessage() - val closingAlice = aliceActions5.hasOutgoingMessage() + val closingAlice = aliceActions5.hasOutgoingMessage() val (bob5, _) = bob4.process(ChannelCommand.MessageReceived(shutdownAlice)) val (bob6, _) = bob5.process(ChannelCommand.MessageReceived(revAlice)) val (bob7, bobActions7) = bob6.process(ChannelCommand.MessageReceived(closingAlice)) assertIs>(bob7) - val closingBob = bobActions7.hasOutgoingMessage() + val closingBob = bobActions7.hasOutgoingMessage() val (alice6, _) = alice5.process(ChannelCommand.MessageReceived(closingBob)) assertIs>(alice6) } @@ -1686,22 +1677,9 @@ class NormalTestsCommon : LightningTestSuite() { actions1.hasWatch() } - @Test - fun `recv Shutdown -- with unsupported native segwit script`() { - val (_, bob) = reachNormal() - val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) - assertIs>(bob1) - actions1.hasOutgoingMessage() - assertEquals(2, actions1.filterIsInstance().count()) - actions1.hasWatch() - } - @Test fun `recv Shutdown -- with native segwit script`() { - val (_, bob) = reachNormal( - aliceFeatures = TestConstants.Alice.nodeParams.features.copy(TestConstants.Alice.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - bobFeatures = TestConstants.Bob.nodeParams.features.copy(TestConstants.Bob.nodeParams.features.activated + (Feature.ShutdownAnySegwit to FeatureSupport.Optional)), - ) + val (_, bob) = reachNormal() val (bob1, actions1) = bob.process(ChannelCommand.MessageReceived(Shutdown(bob.channelId, ByteVector("51050102030405")))) assertIs>(bob1) actions1.hasOutgoingMessage() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt index 6d92d0c9b..363de0a25 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/QuiescenceTestsCommon.kt @@ -321,9 +321,10 @@ class QuiescenceTestsCommon : LightningTestSuite() { val (alice2, actionsAlice2) = alice1.process(ChannelCommand.MessageReceived(shutdownBob)) assertIs(alice2.state) val shutdownAlice = actionsAlice2.findOutgoingMessage() - actionsAlice2.findOutgoingMessage() + actionsAlice2.findOutgoingMessage() val (bob3, actionsBob3) = bob2.process(ChannelCommand.MessageReceived(shutdownAlice)) assertIs(bob3.state) + actionsBob3.findOutgoingMessage() actionsBob3.has() } 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 bbcec0e7e..c58d20e4d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ShutdownTestsCommon.kt @@ -10,6 +10,7 @@ import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_SPENT import fr.acinq.lightning.blockchain.WatchConfirmed import fr.acinq.lightning.blockchain.WatchEventSpent import fr.acinq.lightning.blockchain.WatchSpent +import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.* import fr.acinq.lightning.channel.TestsHelper.addHtlc import fr.acinq.lightning.channel.TestsHelper.claimHtlcSuccessTxs @@ -472,9 +473,23 @@ class ShutdownTestsCommon : LightningTestSuite() { @Test fun `recv ChannelCommand_Close_MutualClose`() { val (alice, _) = init() - val (alice1, actions) = alice.process(ChannelCommand.Close.MutualClose(null, null)) - assertEquals(alice1, alice) - assertEquals(actions, listOf(ChannelAction.ProcessCmdRes.NotExecuted(ChannelCommand.Close.MutualClose(null, null), ClosingAlreadyInProgress(alice.channelId)))) + + // Update our closing feerate. + val (alice1, actions1) = alice.process(ChannelCommand.Close.MutualClose(null, FeeratePerKw(5000.sat()))) + assertIs(alice1.state) + assertEquals(FeeratePerKw(5000.sat()), alice1.state.closingFeerate) + assertEquals(1, actions1.size) + actions1.has() + + // Update our closing script. + val closingScript = Script.write(Script.pay2wpkh(randomKey().publicKey())).byteVector() + val (alice2, actions2) = alice1.process(ChannelCommand.Close.MutualClose(closingScript, null)) + assertIs(alice2.state) + assertEquals(FeeratePerKw(5000.sat()), alice2.state.closingFeerate) + assertEquals(2, actions2.size) + actions2.has() + val shutdown = actions2.findOutgoingMessage() + assertEquals(shutdown, alice2.state.localShutdown) } private fun testLocalForceClose(alice: LNChannel, actions: List) { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt index ab9d2e835..42fde6d3d 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandlerTestsCommon.kt @@ -52,11 +52,12 @@ class OutgoingPaymentHandlerTestsCommon : LightningTestSuite() { Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory, Feature.BasicMultiPartPayment to FeatureSupport.Optional, - Feature.DualFunding to FeatureSupport.Mandatory + Feature.DualFunding to FeatureSupport.Mandatory, + Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.SimpleClose to FeatureSupport.Mandatory, ) // The following invoice requires payment_metadata. - val invoice1 = - PaymentRequestTestsCommon.createInvoiceUnsafe(features = Features(Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Mandatory)) + val invoice1 = PaymentRequestTestsCommon.createInvoiceUnsafe(features = Features(Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Mandatory)) // The following invoice requires unknown feature bit 188. val invoice2 = PaymentRequestTestsCommon.createInvoiceUnsafe(features = Features(mapOf(Feature.VariableLengthOnion to FeatureSupport.Mandatory, Feature.PaymentSecret to FeatureSupport.Mandatory), setOf(UnknownFeature(188)))) for (invoice in listOf(invoice1, invoice2)) { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt index 2c4509f1a..8d6c442d0 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/tests/TestConstants.kt @@ -66,6 +66,8 @@ object TestConstants { Feature.DualFunding to FeatureSupport.Mandatory, Feature.ChannelType to FeatureSupport.Mandatory, Feature.PaymentMetadata to FeatureSupport.Optional, + Feature.ShutdownAnySegwit to FeatureSupport.Mandatory, + Feature.SimpleClose to FeatureSupport.Mandatory, Feature.ExperimentalTrampolinePayment to FeatureSupport.Optional, Feature.WakeUpNotificationProvider to FeatureSupport.Optional, Feature.PayToOpenProvider to FeatureSupport.Optional, diff --git a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt index c82be7b9c..2f4f002c9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt @@ -43,7 +43,7 @@ import fr.acinq.lightning.transactions.Transactions.makeClaimHtlcSuccessTx import fr.acinq.lightning.transactions.Transactions.makeClaimHtlcTimeoutTx import fr.acinq.lightning.transactions.Transactions.makeClaimLocalDelayedOutputTx import fr.acinq.lightning.transactions.Transactions.makeClaimRemoteDelayedOutputTx -import fr.acinq.lightning.transactions.Transactions.makeClosingTx +import fr.acinq.lightning.transactions.Transactions.makeClosingTxs import fr.acinq.lightning.transactions.Transactions.makeCommitTx import fr.acinq.lightning.transactions.Transactions.makeCommitTxOutputs import fr.acinq.lightning.transactions.Transactions.makeHtlcPenaltyTx @@ -580,54 +580,63 @@ class TransactionsTestsCommon : LightningTestSuite() { @Test fun `find our output in closing tx`() { - val localPubKeyScript = write(pay2wpkh(PrivateKey(randomBytes32()).publicKey())) - val remotePubKeyScript = write(pay2wpkh(PrivateKey(randomBytes32()).publicKey())) + val localPubKeyScript = write(pay2wpkh(PrivateKey(randomBytes32()).publicKey())).byteVector() + val remotePubKeyScript = write(pay2wpkh(PrivateKey(randomBytes32()).publicKey())).byteVector() run { - // Different amounts, both outputs untrimmed, local is the initiator: + // Different amounts, both outputs untrimmed, local is closer: val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 250_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) - assertEquals(2, closingTx.tx.txOut.size) - assertNotNull(closingTx.toLocalIndex) - assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) - assertEquals(149_000.sat, closingTx.toLocalOutput!!.amount) // initiator pays the fee - val toRemoteIndex = (closingTx.toLocalIndex!! + 1) % 2 - assertEquals(250_000.sat, closingTx.tx.txOut[toRemoteIndex].amount) + val closingTxs = makeClosingTxs(commitInput, spec, Transactions.ClosingTxFee.PaidByUs(5_000.sat), 0xFFFFFFFDL, 0, localPubKeyScript, remotePubKeyScript) + assertNotNull(closingTxs.localAndRemote) + assertNotNull(closingTxs.localOnly) + assertNull(closingTxs.remoteOnly) + val localAndRemote = closingTxs.localAndRemote?.toLocalOutput!! + assertEquals(localPubKeyScript, localAndRemote.publicKeyScript) + assertEquals(145_000.sat, localAndRemote.amount) + val localOnly = closingTxs.localOnly?.toLocalOutput!! + assertEquals(localPubKeyScript, localOnly.publicKeyScript) + assertEquals(145_000.sat, localOnly.amount) } run { - // Same amounts, both outputs untrimmed, local is not the initiator: - val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 150_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = false, localDustLimit, 1000.sat, spec) - assertEquals(2, closingTx.tx.txOut.size) - assertNotNull(closingTx.toLocalIndex) - assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) - assertEquals(150_000.sat, closingTx.toLocalOutput!!.amount) - val toRemoteIndex = (closingTx.toLocalIndex!! + 1) % 2 - assertEquals(149_000.sat, closingTx.tx.txOut[toRemoteIndex].amount) + // Same amounts, both outputs untrimmed, remote is closer: + val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 250_000_000.msat) + val closingTxs = makeClosingTxs(commitInput, spec, Transactions.ClosingTxFee.PaidByThem(5_000.sat), 0xFFFFFFFDL, 0, localPubKeyScript, remotePubKeyScript) + assertNotNull(closingTxs.localAndRemote) + assertNotNull(closingTxs.localOnly) + assertNull(closingTxs.remoteOnly) + val localAndRemote = closingTxs.localAndRemote?.toLocalOutput!! + assertEquals(localPubKeyScript, localAndRemote.publicKeyScript) + assertEquals(150_000.sat, localAndRemote.amount) + val localOnly = closingTxs.localOnly?.toLocalOutput!! + assertEquals(localPubKeyScript, localOnly.publicKeyScript) + assertEquals(150_000.sat, localOnly.amount) } run { // Their output is trimmed: - val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 1_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = false, localDustLimit, 1000.sat, spec) - assertEquals(1, closingTx.tx.txOut.size) - assertNotNull(closingTx.toLocalOutput) - assertEquals(localPubKeyScript.toByteVector(), closingTx.toLocalOutput!!.publicKeyScript) - assertEquals(150_000.sat, closingTx.toLocalOutput!!.amount) - assertEquals(0, closingTx.toLocalIndex!!) + val spec = CommitmentSpec(setOf(), feerate, 150_000_000.msat, 1_000_000.msat) + val closingTxs = makeClosingTxs(commitInput, spec, Transactions.ClosingTxFee.PaidByThem(800.sat), 0xFFFFFFFDL, 0, localPubKeyScript, remotePubKeyScript) + assertEquals(1, closingTxs.all.size) + assertNotNull(closingTxs.localOnly) + assertEquals(1, closingTxs.localOnly!!.tx.txOut.size) + val toLocal = closingTxs.localOnly?.toLocalOutput!! + assertEquals(localPubKeyScript, toLocal.publicKeyScript) + assertEquals(150_000.sat, toLocal.amount) } run { // Our output is trimmed: - val spec = CommitmentSpec(setOf(), feerate, 50_000.msat, 150_000_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) - assertEquals(1, closingTx.tx.txOut.size) - assertNull(closingTx.toLocalOutput) + val spec = CommitmentSpec(setOf(), feerate, 1_000_000.msat, 150_000_000.msat) + val closingTxs = makeClosingTxs(commitInput, spec, Transactions.ClosingTxFee.PaidByUs(800.sat), 0xFFFFFFFDL, 0, localPubKeyScript, remotePubKeyScript) + assertEquals(1, closingTxs.all.size) + assertNotNull(closingTxs.remoteOnly) + assertNull(closingTxs.remoteOnly?.toLocalOutput) } run { // Both outputs are trimmed: val spec = CommitmentSpec(setOf(), feerate, 50_000.msat, 10_000.msat) - val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000.sat, spec) - assertTrue(closingTx.tx.txOut.isEmpty()) - assertNull(closingTx.toLocalOutput) + val closingTxs = makeClosingTxs(commitInput, spec, Transactions.ClosingTxFee.PaidByUs(10.sat), 0xFFFFFFFDL, 0, localPubKeyScript, remotePubKeyScript) + assertNull(closingTxs.localAndRemote) + assertNull(closingTxs.localOnly) + assertNull(closingTxs.remoteOnly) } } diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 2498ab8f1..ee1387e6a 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -593,54 +593,6 @@ 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(ClosingSignedTlv.FeeRange(100.sat, 2000.sat)) - ), - Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 00000000000003e8 01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 0110000000000000006400000000000007d0") to ClosingSigned( - ByteVector32.One, - 1000.sat, - defaultSig, - TlvStream(ClosingSignedTlv.FeeRange(100.sat, 2000.sat)) - ), - Hex.decode("0027 0100000000000000000000000000000000000000000000000000000000000000 0000000000000064 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 0110000000000000006400000000000003e8 030401020304") to ClosingSigned( - ByteVector32.One, - 100.sat, - ByteVector64.Zeroes, - TlvStream(setOf(ClosingSignedTlv.FeeRange(100.sat, 1000.sat)), setOf(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 `encode - decode closing messages`() { val channelId = ByteVector32("58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86") @@ -713,13 +665,12 @@ class LightningCodecsTestsCommon : LightningTestSuite() { Hex.decode("0026") + channelId.toByteArray() + Hex.decode("002a") + randomData + Hex.decode("01 02 0102") + Hex.decode("fe47010000 00") to Shutdown(channelId, randomData.toByteVector(), TlvStream(setOf(ShutdownTlv.ChannelData(EncryptedChannelData.empty)), setOf(GenericTlv(1, ByteVector("0102"))))), Hex.decode("0026") + channelId.toByteArray() + Hex.decode("002a") + randomData + Hex.decode("fe47010000 07 cccccccccccccc") to Shutdown(channelId, randomData.toByteVector()).withChannelData(ByteVector("cccccccccccccc")), 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(setOf(ShutdownTlv.ChannelData(EncryptedChannelData(ByteVector("cccccccccccccc")))), setOf(GenericTlv(1, ByteVector("0102"))))), - // closing_signed - 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("03 02 0102") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(setOf(), setOf(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(ClosingSignedTlv.ChannelData(EncryptedChannelData.empty))), - 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(setOf(ClosingSignedTlv.ChannelData(EncryptedChannelData.empty)), setOf(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("03 02 0102") + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingSigned(channelId, 123456789.sat, signature, TlvStream(setOf(ClosingSignedTlv.ChannelData(EncryptedChannelData(ByteVector("cccccccccccccc")))), setOf(GenericTlv(3, ByteVector("0102"))))) + // closing_complete + Hex.decode("0028") + channelId.toByteArray() + Hex.decode("0000000000000451 00000000 00000000") + Hex.decode("fe47010000 00") to ClosingComplete(channelId, 1105.sat, 0u, 0, TlvStream(ClosingCompleteTlv.ChannelData(EncryptedChannelData.empty))), + Hex.decode("0028") + channelId.toByteArray() + Hex.decode("0000000000000451 00000000 00000000") + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingComplete(channelId, 1105.sat, 0u, 0).withChannelData(ByteVector("cccccccccccccc")), + // closing_sig + Hex.decode("0029") + channelId.toByteArray() + Hex.decode("fe47010000 00") to ClosingSig(channelId, TlvStream(ClosingSigTlv.ChannelData(EncryptedChannelData.empty))), + Hex.decode("0029") + channelId.toByteArray() + Hex.decode("fe47010000 07 cccccccccccccc") to ClosingSig(channelId).withChannelData(ByteVector("cccccccccccccc")), ) // @formatter:on @@ -742,7 +693,8 @@ class LightningCodecsTestsCommon : LightningTestSuite() { CommitSig(randomBytes32(), randomBytes64(), listOf()), RevokeAndAck(randomBytes32(), randomKey(), randomKey().publicKey()), Shutdown(randomBytes32(), ByteVector("deadbeef")), - ClosingSigned(randomBytes32(), 0.sat, randomBytes64()), + ClosingComplete(randomBytes32(), 250.sat, 0U, 0L), + ClosingSig(randomBytes32()), ) messages.forEach { assertEquals(it.withChannelData(belowLimit).channelData, belowLimit)