From 7eb80e2af928e67039285886a4faa32627a5ef1c Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Fri, 3 Nov 2023 09:13:23 +0100 Subject: [PATCH] Send commit sigs for alternative feerates (#553) When the commitment transaction doesn't contain any HTLC, we send additional signatures for alternative feerates. This lets the wallet provider force-close with a more interesting feerate when the wallet user disappears without closing their channels. --- .../fr/acinq/lightning/channel/Commitments.kt | 39 +++++++++++- .../acinq/lightning/channel/InteractiveTx.kt | 18 +++++- .../acinq/lightning/channel/states/Channel.kt | 54 +++++++++++----- .../acinq/lightning/json/JsonSerializers.kt | 10 ++- .../fr/acinq/lightning/wire/ChannelTlv.kt | 32 ++++++++++ .../acinq/lightning/wire/LightningMessages.kt | 2 + .../fr/acinq/lightning/channel/TestsHelper.kt | 24 ++++++++ .../channel/states/ClosingTestsCommon.kt | 61 ++++++++++++++++++- .../channel/states/SpliceTestsCommon.kt | 52 +++++++++++++++- .../wire/LightningCodecsTestsCommon.kt | 26 ++++++++ 10 files changed, 295 insertions(+), 23 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt index b0309c6fd..7868688d5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.Crypto.sha256 import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Feature import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.blockchain.fee.FeeratePerByte import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.blockchain.fee.FeerateTolerance import fr.acinq.lightning.channel.states.Channel @@ -421,7 +422,18 @@ data class Commitment( "built remote commit number=${remoteCommit.index + 1} toLocalMsat=${spec.toLocal.toLong()} toRemoteMsat=${spec.toRemote.toLong()} htlc_in=$htlcsIn htlc_out=$htlcsOut feeratePerKw=${spec.feerate} txId=${remoteCommitTx.tx.txid} fundingTxId=$fundingTxId" } - val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList()) + val tlvs = buildSet { + if (spec.htlcs.isEmpty()) { + val alternativeSigs = Commitments.alternativeFeerates.map { feerate -> + val alternativeSpec = spec.copy(feerate = feerate) + val (alternativeRemoteCommitTx, _) = Commitments.makeRemoteTxs(channelKeys, commitTxNumber = remoteCommit.index + 1, params.localParams, params.remoteParams, fundingTxIndex = fundingTxIndex, remoteFundingPubKey = remoteFundingPubkey, commitInput, remotePerCommitmentPoint = remoteNextPerCommitmentPoint, alternativeSpec) + val alternativeSig = Transactions.sign(alternativeRemoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) + CommitSigTlv.AlternativeFeerateSig(feerate, alternativeSig) + } + add(CommitSigTlv.AlternativeFeerateSigs(alternativeSigs)) + } + } + val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList(), TlvStream(tlvs)) val commitment1 = copy(nextRemoteCommit = NextRemoteCommit(commitSig, RemoteCommit(remoteCommit.index + 1, spec, remoteCommitTx.tx.txid, remoteNextPerCommitmentPoint))) return Pair(commitment1, commitSig) } @@ -933,6 +945,31 @@ data class Commitments( const val HTLC_TIMEOUT_WEIGHT = 666 const val HTLC_SUCCESS_WEIGHT = 706 + /** + * Alternative feerates at which we will sign commitment transactions that have no pending HTLCs. + * WARNING: never remove a feerate from this list, we can only add more, otherwise we will not be able to detect when our peer broadcasts the commit tx at the removed feerate. + */ + val alternativeFeerates = listOf(1.sat, 2.sat, 5.sat, 10.sat).map { FeeratePerKw(FeeratePerByte(it)) } + + /** + * Our peer may publish an alternative version of their commitment using a different feerate. + * This function lists all the alternative commitments they have signatures for. + */ + fun alternativeFeerateCommits(commitments: Commitments, channelKeys: KeyManager.ChannelKeys): List { + return buildList { + add(commitments.latest.remoteCommit) + commitments.latest.nextRemoteCommit?.let { add(it.commit) } + }.filter { remoteCommit -> + remoteCommit.spec.htlcs.isEmpty() + }.flatMap { remoteCommit -> + alternativeFeerates.map { feerate -> + val alternativeSpec = remoteCommit.spec.copy(feerate = feerate) + val (alternativeRemoteCommitTx, _) = makeRemoteTxs(channelKeys, remoteCommit.index, commitments.params.localParams, commitments.params.remoteParams, commitments.latest.fundingTxIndex, commitments.latest.remoteFundingPubkey, commitments.latest.commitInput, remoteCommit.remotePerCommitmentPoint, alternativeSpec) + RemoteCommit(remoteCommit.index, alternativeSpec, alternativeRemoteCommitTx.tx.txid, remoteCommit.remotePerCommitmentPoint) + } + } + } + fun makeLocalTxs( channelKeys: KeyManager.ChannelKeys, commitTxNumber: Long, diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 44261068e..af65d8a4b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -849,7 +849,23 @@ data class InteractiveTxSigningSession( remotePerCommitmentPoint = remotePerCommitmentPoint ).map { firstCommitTx -> val localSigOfRemoteTx = Transactions.sign(firstCommitTx.remoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) - val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf()) + val alternativeSigs = Commitments.alternativeFeerates.map { feerate -> + val alternativeSpec = firstCommitTx.remoteSpec.copy(feerate = feerate) + val (alternativeRemoteCommitTx, _) = Commitments.makeRemoteTxs( + channelKeys, + remoteCommitmentIndex, + channelParams.localParams, + channelParams.remoteParams, + fundingTxIndex, + fundingParams.remoteFundingPubkey, + firstCommitTx.remoteCommitTx.input, + remotePerCommitmentPoint, + alternativeSpec + ) + val alternativeSig = Transactions.sign(alternativeRemoteCommitTx, channelKeys.fundingKey(fundingTxIndex)) + CommitSigTlv.AlternativeFeerateSig(feerate, alternativeSig) + } + val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf(), TlvStream(CommitSigTlv.AlternativeFeerateSigs(alternativeSigs))) val unsignedLocalCommit = UnsignedLocalCommit(localCommitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf()) val remoteCommit = RemoteCommit(remoteCommitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint) val signedFundingTx = sharedTx.sign(keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId) 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 a49274f17..f2287ea28 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Channel.kt @@ -1,7 +1,13 @@ package fr.acinq.lightning.channel.states -import fr.acinq.bitcoin.* -import fr.acinq.lightning.* +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.Transaction +import fr.acinq.lightning.CltvExpiryDelta +import fr.acinq.lightning.Feature +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.SensitiveTaskEvents import fr.acinq.lightning.blockchain.* import fr.acinq.lightning.blockchain.fee.OnChainFeerates import fr.acinq.lightning.channel.* @@ -13,7 +19,7 @@ import fr.acinq.lightning.channel.Helpers.Closing.getRemotePerCommitmentSecret import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.serialization.Encryption.from -import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.* +import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -517,18 +523,36 @@ sealed class ChannelStateWithCommitments : PersistedChannelState() { }) } else -> { - logger.warning { "unrecognized tx=${tx.txid}" } - // This can happen if the user has two devices. - // - user creates a wallet on device #1 - // - user restores the same wallet on device #2 - // - user does a splice on device #2 - // - user starts wallet on device #1 - // The wallet on device #1 has a previous version of the channel, it is not aware of the splice tx. It won't be able - // to recognize the tx when the watcher notifies that the (old) funding tx was spent. - // However, there is a race with the reconnection logic, because then the device #1 will recover its latest state from the - // remote backup. - // So, the best thing to do here is to ignore the spending tx. - Pair(this@ChannelStateWithCommitments, listOf()) + // Our peer may publish an alternative version of their commitment using a different feerate. + when (val remoteCommit = Commitments.alternativeFeerateCommits(commitments, channelKeys()).find { it.txid == tx.txid }) { + null -> { + logger.warning { "unrecognized tx=${tx.txid}" } + // This can happen if the user has two devices. + // - user creates a wallet on device #1 + // - user restores the same wallet on device #2 + // - user does a splice on device #2 + // - user starts wallet on device #1 + // The wallet on device #1 has a previous version of the channel, it is not aware of the splice tx. It won't be able + // to recognize the tx when the watcher notifies that the (old) funding tx was spent. + // However, there is a race with the reconnection logic, because then the device #1 will recover its latest state from the + // remote backup. + // So, the best thing to do here is to ignore the spending tx. + Pair(this@ChannelStateWithCommitments, listOf()) + } + else -> { + logger.warning { "they published an alternative commitment with feerate=${remoteCommit.spec.feerate} txid=${tx.txid}" } + 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) + else -> Closing(commitments, waitingSinceBlock = currentBlockHeight.toLong(), remoteCommitPublished = remoteCommitPublished) + } + return Pair(nextState, buildList { + add(ChannelAction.Storage.StoreState(nextState)) + addAll(remoteCommitPublished.run { doPublish(channelId, staticParams.nodeParams.minDepthBlocks.toLong()) }) + }) + } + } } } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt index 563476660..0251c31f5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/json/JsonSerializers.kt @@ -64,7 +64,6 @@ JsonSerializers.CommitmentChangesSerializer::class, JsonSerializers.LocalFundingStatusSerializer::class, JsonSerializers.RemoteFundingStatusSerializer::class, - JsonSerializers.EncryptedChannelDataSerializer::class, JsonSerializers.ShutdownSerializer::class, JsonSerializers.ClosingSignedSerializer::class, JsonSerializers.UpdateAddHtlcSerializer::class, @@ -82,6 +81,8 @@ JsonSerializers.ClosingSignedTlvSerializer::class, JsonSerializers.ChannelReestablishTlvSerializer::class, JsonSerializers.ChannelReadyTlvSerializer::class, + JsonSerializers.CommitSigTlvAlternativeFeerateSigSerializer::class, + JsonSerializers.CommitSigTlvAlternativeFeerateSigsSerializer::class, JsonSerializers.CommitSigTlvSerializer::class, JsonSerializers.UUIDSerializer::class, JsonSerializers.ClosingSerializer::class, @@ -184,6 +185,7 @@ object JsonSerializers { } polymorphic(Tlv::class) { subclass(ChannelReadyTlv.ShortChannelIdTlv::class, ChannelReadyTlvShortChannelIdTlvSerializer) + subclass(CommitSigTlv.AlternativeFeerateSigs::class, CommitSigTlvAlternativeFeerateSigsSerializer) subclass(ShutdownTlv.ChannelData::class, ShutdownTlvChannelDataSerializer) subclass(ClosingSignedTlv.FeeRange::class, ClosingSignedTlvFeeRangeSerializer) } @@ -486,6 +488,12 @@ object JsonSerializers { @Serializer(forClass = ShutdownTlv::class) object ShutdownTlvSerializer + @Serializer(forClass = CommitSigTlv.AlternativeFeerateSig::class) + object CommitSigTlvAlternativeFeerateSigSerializer + + @Serializer(forClass = CommitSigTlv.AlternativeFeerateSigs::class) + object CommitSigTlvAlternativeFeerateSigsSerializer + @Serializer(forClass = CommitSigTlv::class) object CommitSigTlvSerializer diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt index abec2ba24..5e8162b70 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/ChannelTlv.kt @@ -6,6 +6,7 @@ import fr.acinq.bitcoin.io.Output 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.ChannelType import fr.acinq.lightning.channel.Origin import fr.acinq.lightning.utils.* @@ -172,6 +173,37 @@ sealed class CommitSigTlv : Tlv { } } + data class AlternativeFeerateSig(val feerate: FeeratePerKw, val sig: ByteVector64) + + /** + * When there are no pending HTLCs, we provide a list of signatures for the commitment transaction signed at various feerates. + * This gives more options to the remote node to recover their funds if the user disappears without closing channels. + */ + data class AlternativeFeerateSigs(val sigs: List) : CommitSigTlv() { + override val tag: Long get() = AlternativeFeerateSigs.tag + override fun write(out: Output) { + LightningCodecs.writeByte(sigs.size, out) + sigs.forEach { + LightningCodecs.writeU32(it.feerate.toLong().toInt(), out) + LightningCodecs.writeBytes(it.sig, out) + } + } + + companion object : TlvValueReader { + const val tag: Long = 0x47010001 + override fun read(input: Input): AlternativeFeerateSigs { + val count = LightningCodecs.byte(input) + val sigs = (0 until count).map { + AlternativeFeerateSig( + FeeratePerKw(LightningCodecs.u32(input).toLong().sat), + LightningCodecs.bytes(input, 64).toByteVector64() + ) + } + return AlternativeFeerateSigs(sigs) + } + } + } + data class Batch(val size: Int) : CommitSigTlv() { override val tag: Long get() = Batch.tag override fun write(out: Output) = LightningCodecs.writeTU16(size, out) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 58148e87e..5b69146bb 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -1101,6 +1101,7 @@ data class CommitSig( override val channelData: EncryptedChannelData get() = tlvStream.get()?.ecb ?: EncryptedChannelData.empty override fun withNonEmptyChannelData(ecd: EncryptedChannelData): CommitSig = copy(tlvStream = tlvStream.addOrUpdate(CommitSigTlv.ChannelData(ecd))) + val alternativeFeerateSigs: List = tlvStream.get()?.sigs ?: listOf() val batchSize: Int = tlvStream.get()?.size ?: 1 override fun write(out: Output) { @@ -1117,6 +1118,7 @@ data class CommitSig( @Suppress("UNCHECKED_CAST") val readers = mapOf( CommitSigTlv.ChannelData.tag to CommitSigTlv.ChannelData.Companion as TlvValueReader, + CommitSigTlv.AlternativeFeerateSigs.tag to CommitSigTlv.AlternativeFeerateSigs.Companion as TlvValueReader, CommitSigTlv.Batch.tag to CommitSigTlv.Batch.Companion as TlvValueReader, ) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt index 4a4816b71..2b012dc39 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/TestsHelper.kt @@ -358,6 +358,30 @@ object TestsHelper { return s1 to remoteCommitPublished } + fun useAlternativeCommitSig(s: LNChannel, commitment: Commitment, alternative: CommitSigTlv.AlternativeFeerateSig): Transaction { + val channelKeys = s.commitments.params.localParams.channelKeys(s.ctx.keyManager) + val alternativeSpec = commitment.localCommit.spec.copy(feerate = alternative.feerate) + val fundingTxIndex = commitment.fundingTxIndex + val commitInput = commitment.commitInput + val remoteFundingPubKey = commitment.remoteFundingPubkey + val localPerCommitmentPoint = channelKeys.commitmentPoint(commitment.localCommit.index) + val (localCommitTx, _) = Commitments.makeLocalTxs( + channelKeys, + commitment.localCommit.index, + s.commitments.params.localParams, + s.commitments.params.remoteParams, + fundingTxIndex, + remoteFundingPubKey, + commitInput, + localPerCommitmentPoint, + alternativeSpec + ) + val localSig = Transactions.sign(localCommitTx, channelKeys.fundingKey(fundingTxIndex)) + val signedCommitTx = Transactions.addSigs(localCommitTx, channelKeys.fundingPubKey(fundingTxIndex), remoteFundingPubKey, localSig, alternative.sig) + assertTrue(Transactions.checkSpendable(signedCommitTx).isSuccess) + return signedCommitTx.tx + } + fun signAndRevack(alice: LNChannel, bob: LNChannel): Pair, LNChannel> { val (alice1, actions1) = alice.process(ChannelCommand.Commitment.Sign) val commitSig = actions1.findOutgoingMessage() 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 11bfd828f..0b4223b32 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/ClosingTestsCommon.kt @@ -20,9 +20,9 @@ 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 import fr.acinq.lightning.db.ChannelClosingType import fr.acinq.lightning.tests.TestConstants import fr.acinq.lightning.tests.utils.LightningTestSuite @@ -708,6 +708,38 @@ class ClosingTestsCommon : LightningTestSuite() { confirmWatchedTxs(aliceFulfill, watchConfirmed) } + @Test + fun `recv BITCOIN_TX_CONFIRMED -- remote commit -- alternative feerate`() { + val (alice0, bob0) = reachNormal() + val (bobClosing, remoteCommitPublished) = run { + val (nodes1, r, htlc) = addHtlc(75_000_000.msat, alice0, bob0) + val (alice1, bob1) = nodes1 + val (alice2, bob2) = crossSign(alice1, bob1) + val (alice3, bob3) = fulfillHtlc(htlc.id, r, alice2, bob2) + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Commitment.Sign) + val commitSigBob = actionsBob4.hasOutgoingMessage() + val (alice4, actionsAlice4) = alice3.process(ChannelCommand.MessageReceived(commitSigBob)) + val revAlice = actionsAlice4.hasOutgoingMessage() + val (alice5, actionsAlice5) = alice4.process(ChannelCommand.Commitment.Sign) + val commitSigAlice = actionsAlice5.hasOutgoingMessage() + val (bob5, _) = bob4.process(ChannelCommand.MessageReceived(revAlice)) + val (bob6, actionsBob6) = bob5.process(ChannelCommand.MessageReceived(commitSigAlice)) + val revBob = actionsBob6.hasOutgoingMessage() + val (alice6, _) = alice5.process(ChannelCommand.MessageReceived(revBob)) + val alternativeCommitTx = useAlternativeCommitSig(alice6, alice6.commitments.active.first(), commitSigBob.alternativeFeerateSigs.first()) + remoteClose(alternativeCommitTx, bob6) + } + + assertNotNull(remoteCommitPublished.claimMainOutputTx) + val claimMain = remoteCommitPublished.claimMainOutputTx!!.tx + Transaction.correctlySpends(claimMain, remoteCommitPublished.commitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + + val (bobClosing1, _) = bobClosing.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob0.channelId, BITCOIN_TX_CONFIRMED(remoteCommitPublished.commitTx), 42, 0, remoteCommitPublished.commitTx))) + val (bobClosed, actions) = bobClosing1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob0.channelId, BITCOIN_TX_CONFIRMED(claimMain), 43, 0, claimMain))) + assertIs(bobClosed.state) + assertTrue(actions.contains(ChannelAction.Storage.StoreState(bobClosed.state))) + } + @Test fun `recv BITCOIN_OUTPUT_SPENT -- remote commit`() { val (alice0, bob0) = reachNormal() @@ -898,7 +930,7 @@ class ClosingTestsCommon : LightningTestSuite() { } @Test - fun `recv BITCOIN_TX_CONFIRMED -- next remote commit -- followed by CMD_FULFILL_HTLC`() { + fun `recv BITCOIN_TX_CONFIRMED -- next remote commit -- followed by CMD_FULFILL_HTLC`() { val (alice0, bob0) = reachNormal() val (aliceClosing, remoteCommitPublished, fulfill) = run { // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. @@ -949,6 +981,31 @@ class ClosingTestsCommon : LightningTestSuite() { confirmWatchedTxs(aliceFulfill, watchConfirmed) } + @Test + fun `recv BITCOIN_TX_CONFIRMED -- next remote commit -- alternative feerate`() { + val (alice0, bob0) = reachNormal() + val (bobClosing, remoteCommitPublished) = run { + val (nodes1, r, htlc) = addHtlc(75_000_000.msat, alice0, bob0) + val (alice1, bob1) = nodes1 + val (alice2, bob2) = crossSign(alice1, bob1) + val (alice3, bob3) = fulfillHtlc(htlc.id, r, alice2, bob2) + val (bob4, actionsBob4) = bob3.process(ChannelCommand.Commitment.Sign) + val commitSigBob = actionsBob4.hasOutgoingMessage() + val (alice4, _) = alice3.process(ChannelCommand.MessageReceived(commitSigBob)) + val alternativeCommitTx = useAlternativeCommitSig(alice4, alice4.commitments.active.first(), commitSigBob.alternativeFeerateSigs.first()) + remoteClose(alternativeCommitTx, bob4) + } + + assertNotNull(remoteCommitPublished.claimMainOutputTx) + val claimMain = remoteCommitPublished.claimMainOutputTx!!.tx + Transaction.correctlySpends(claimMain, remoteCommitPublished.commitTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + + val (bobClosing1, _) = bobClosing.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob0.channelId, BITCOIN_TX_CONFIRMED(remoteCommitPublished.commitTx), 42, 0, remoteCommitPublished.commitTx))) + val (bobClosed, actions) = bobClosing1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(bob0.channelId, BITCOIN_TX_CONFIRMED(claimMain), 43, 0, claimMain))) + assertIs(bobClosed.state) + assertTrue(actions.contains(ChannelAction.Storage.StoreState(bobClosed.state))) + } + @Test fun `recv BITCOIN_OUTPUT_SPENT -- next remote commit`() { val (alice0, bob0) = reachNormal() diff --git a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt index 722d19558..3b6110b4e 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/channel/states/SpliceTestsCommon.kt @@ -11,6 +11,7 @@ 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.reachNormal +import fr.acinq.lightning.channel.TestsHelper.useAlternativeCommitSig import fr.acinq.lightning.crypto.KeyManager import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.msat @@ -824,6 +825,21 @@ class SpliceTestsCommon : LightningTestSuite() { handleRemoteClose(alice2, actionsAlice2, commitment, bobCommitTx) } + @Test + fun `force-close -- latest active commitment -- alternative feerate`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, commitSigAlice, bob1, commitSigBob) = spliceOutWithoutSigs(alice, bob, 75_000.sat) + val (alice2, bob2) = exchangeSpliceSigs(alice1, commitSigAlice, bob1, commitSigBob) + + // Bob force-closes using the latest active commitment and an optional feerate. + val bobCommitTx = useAlternativeCommitSig(bob2, bob2.commitments.active.first(), commitSigAlice.alternativeFeerateSigs.last()) + val commitment = alice1.commitments.active.first() + val (alice3, actionsAlice3) = alice2.process(ChannelCommand.WatchReceived(WatchEventSpent(alice.channelId, BITCOIN_FUNDING_SPENT, bobCommitTx))) + assertIs>(alice3) + actionsAlice3.has() + handleRemoteClose(alice3, actionsAlice3, commitment, bobCommitTx) + } + @Test fun `force-close -- previous active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() @@ -835,6 +851,20 @@ class SpliceTestsCommon : LightningTestSuite() { handlePreviousRemoteClose(alice1, bobCommitTx) } + @Test + fun `force-close -- previous active commitment -- alternative feerate`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, commitSigAlice1, bob1, commitSigBob1) = spliceOutWithoutSigs(alice, bob, 75_000.sat) + val (alice2, bob2) = exchangeSpliceSigs(alice1, commitSigAlice1, bob1, commitSigBob1) + val (alice3, commitSigAlice3, bob3, commitSigBob3) = spliceOutWithoutSigs(alice2, bob2, 75_000.sat) + val (alice4, bob4) = exchangeSpliceSigs(alice3, commitSigAlice3, bob3, commitSigBob3) + + // Bob force-closes using an older active commitment with an alternative feerate. + assertEquals(bob4.commitments.active.map { it.localCommit.publishableTxs.commitTx.tx }.toSet().size, 3) + val bobCommitTx = useAlternativeCommitSig(bob4, bob4.commitments.active[1], commitSigAlice1.alternativeFeerateSigs.first()) + handlePreviousRemoteClose(alice4, bobCommitTx) + } + @Test fun `force-close -- previous inactive commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx(zeroConf = true) @@ -869,6 +899,23 @@ class SpliceTestsCommon : LightningTestSuite() { handleCurrentRevokedRemoteClose(alice3, bobCommitTx) } + @Test + fun `force-close -- revoked latest active commitment -- alternative feerate`() { + val (alice, bob) = reachNormalWithConfirmedFundingTx() + val (alice1, commitSigAlice, bob1, commitSigBob) = spliceOutWithoutSigs(alice, bob, 50_000.sat) + val (alice2, bob2) = exchangeSpliceSigs(alice1, commitSigAlice, bob1, commitSigBob) + val bobCommitTx = useAlternativeCommitSig(bob2, bob2.commitments.active.first(), commitSigAlice.alternativeFeerateSigs.first()) + + // Alice sends an HTLC to Bob, which revokes the previous commitment. + val (nodes3, _, _) = addHtlc(25_000_000.msat, alice2, bob2) + val (alice4, bob4) = crossSign(nodes3.first, nodes3.second, commitmentsCount = 2) + assertEquals(alice4.commitments.active.size, 2) + assertEquals(bob4.commitments.active.size, 2) + + // Bob force-closes using the revoked commitment. + handleCurrentRevokedRemoteClose(alice4, bobCommitTx) + } + @Test fun `force-close -- revoked previous active commitment`() { val (alice, bob) = reachNormalWithConfirmedFundingTx() @@ -1142,7 +1189,6 @@ class SpliceTestsCommon : LightningTestSuite() { /** Full remote commit resolution from tx detection to channel close */ private fun handleRemoteClose(channel1: LNChannel, actions1: List, commitment: Commitment, remoteCommitTx: Transaction) { assertIs(channel1.state) - assertEquals(commitment.remoteCommit.txid, remoteCommitTx.txid) assertEquals(0, commitment.remoteCommit.spec.htlcs.size, "this helper only supports remote-closing without htlcs") // Spend our outputs from the remote commitment. @@ -1185,9 +1231,9 @@ class SpliceTestsCommon : LightningTestSuite() { val (alice3, actionsAlice3) = alice2.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice2.channelId, BITCOIN_ALTERNATIVE_COMMIT_TX_CONFIRMED, alice2.currentBlockHeight, 43, bobCommitTx))) assertIs>(alice3) // Alice cleans up the commitments. - assertEquals(2, alice2.commitments.active.size) + assertTrue(alice2.commitments.active.size > 1) assertEquals(1, alice3.commitments.active.size) - assertEquals(alice3.commitments.active.first().fundingTxId, alice2.commitments.active.last().fundingTxId) + assertEquals(alice3.commitments.active.first().fundingTxId, bobCommitTx.txIn.first().outPoint.txid) // And processes the remote commit. actionsAlice3.doesNotHave() handleRemoteClose(alice3, actionsAlice3, alice3.commitments.active.first(), bobCommitTx) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index 0b25c5bce..7753c6df7 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -337,6 +337,32 @@ class LightningCodecsTestsCommon : LightningTestSuite() { } } + @Test + fun `encode - decode commit_sig`() { + val channelId = ByteVector32.fromValidHex("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db25") + val signature = ByteVector64.fromValidHex("05e06d9a8fdfbb3625051ff2e3cdf82679cc2268beee6905941d6dd8a067cd62711e04b119a836aa0eebe07545172cefb228860fea6c797178453a319169bed7") + val alternateSigs = listOf( + CommitSigTlv.AlternativeFeerateSig(FeeratePerKw(253.sat), ByteVector64.fromValidHex("c49269a9baa73a5ec44b63bdcaabf9c7c6477f72866b822f8502e5c989aa3562fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c3")), + CommitSigTlv.AlternativeFeerateSig(FeeratePerKw(500.sat), ByteVector64.fromValidHex("2dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db252a2f914ea1fcbd580b80cdea60226f63288cd44bd84a8850c9189a24f08c7cc5")), + CommitSigTlv.AlternativeFeerateSig(FeeratePerKw(750.sat), ByteVector64.fromValidHex("83a7a1a04141ac8ab2818f4a872ea86716ef9aac0852146bcdbc2cc49aecc985899a63513f41ed2502a321a4945689239d12bdab778c1a2e8bf7c3f19ec53b58")), + ) + val backup = EncryptedChannelData(ByteVector.fromHex("fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c303a6680e79e30f050d4f32f1fb9d046cc6efb5ed4cc99eeedba6b2e89cbf838691")) + val testCases = listOf( + // @formatter:off + CommitSig(channelId, signature, listOf(), TlvStream(CommitSigTlv.ChannelData(backup))) to "00842dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db2505e06d9a8fdfbb3625051ff2e3cdf82679cc2268beee6905941d6dd8a067cd62711e04b119a836aa0eebe07545172cefb228860fea6c797178453a319169bed70000fe4701000041fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c303a6680e79e30f050d4f32f1fb9d046cc6efb5ed4cc99eeedba6b2e89cbf838691", + CommitSig(channelId, signature, listOf(), TlvStream(CommitSigTlv.ChannelData(backup), CommitSigTlv.AlternativeFeerateSigs(listOf()))) to "00842dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db2505e06d9a8fdfbb3625051ff2e3cdf82679cc2268beee6905941d6dd8a067cd62711e04b119a836aa0eebe07545172cefb228860fea6c797178453a319169bed70000fe4701000041fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c303a6680e79e30f050d4f32f1fb9d046cc6efb5ed4cc99eeedba6b2e89cbf838691fe470100010100", + CommitSig(channelId, signature, listOf(), TlvStream(CommitSigTlv.AlternativeFeerateSigs(alternateSigs))) to "00842dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db2505e06d9a8fdfbb3625051ff2e3cdf82679cc2268beee6905941d6dd8a067cd62711e04b119a836aa0eebe07545172cefb228860fea6c797178453a319169bed70000fe47010001cd03000000fdc49269a9baa73a5ec44b63bdcaabf9c7c6477f72866b822f8502e5c989aa3562fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c3000001f42dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db252a2f914ea1fcbd580b80cdea60226f63288cd44bd84a8850c9189a24f08c7cc5000002ee83a7a1a04141ac8ab2818f4a872ea86716ef9aac0852146bcdbc2cc49aecc985899a63513f41ed2502a321a4945689239d12bdab778c1a2e8bf7c3f19ec53b58", + CommitSig(channelId, signature, listOf(), TlvStream(CommitSigTlv.ChannelData(backup), CommitSigTlv.AlternativeFeerateSigs(alternateSigs))) to "00842dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db2505e06d9a8fdfbb3625051ff2e3cdf82679cc2268beee6905941d6dd8a067cd62711e04b119a836aa0eebe07545172cefb228860fea6c797178453a319169bed70000fe4701000041fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c303a6680e79e30f050d4f32f1fb9d046cc6efb5ed4cc99eeedba6b2e89cbf838691fe47010001cd03000000fdc49269a9baa73a5ec44b63bdcaabf9c7c6477f72866b822f8502e5c989aa3562fe69d72bec62025d3474b9c2d947ec6d68f9f577be5fab8ee80503cefd8846c3000001f42dadacd65b585e4061421b5265ff543e2a7bdc4d4a7fea932727426bdc53db252a2f914ea1fcbd580b80cdea60226f63288cd44bd84a8850c9189a24f08c7cc5000002ee83a7a1a04141ac8ab2818f4a872ea86716ef9aac0852146bcdbc2cc49aecc985899a63513f41ed2502a321a4945689239d12bdab778c1a2e8bf7c3f19ec53b58", + // @formatter:on + ) + testCases.forEach { (commitSig, bin) -> + val decoded = LightningMessage.decode(Hex.decode(bin)) + assertEquals(decoded, commitSig) + val encoded = LightningMessage.encode(commitSig) + assertEquals(Hex.encode(encoded), bin) + } + } + @Test fun `encode - decode interactive-tx messages`() { val channelId1 = ByteVector32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")