From 0a455f0b41385f10fce5f022a65467684854a495 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 20 Nov 2023 14:06:55 +0100 Subject: [PATCH 1/2] Don't double-spend unspendable inputs When filtering inputs that can be used for swaps, we filter out all the inputs that are already used in pending funding transactions. However, we ignored previously confirmed funding transactions, assuming that our electrum server would have already filtered them from our wallet state. Unfortunately, we cannot assume that our electrum server is up-to-date (especially if we connect to a different one after a restart) and behaving correctly, so we must also exclude inputs use in the latest confirmed funding transaction. --- .../blockchain/electrum/SwapInManager.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt index ecbefa638..d1897c2fe 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManager.kt @@ -57,31 +57,34 @@ class SwapInManager(private var reservedUtxos: Set, private val logger companion object { /** - * Return the list of wallet inputs used in pending unconfirmed channel funding attempts. + * Return the list of wallet inputs already used in channel funding attempts. * These inputs should not be reused in other funding attempts, otherwise we would double-spend ourselves. */ fun reservedWalletInputs(channels: List): Set { - val unconfirmedFundingTxs: List = buildList { + return buildSet { for (channel in channels) { // Add all unsigned inputs currently used to build a funding tx that isn't broadcast yet (creation, rbf, splice). when { - channel is WaitForFundingSigned -> add(channel.signingSession.fundingTx) - channel is WaitForFundingConfirmed && channel.rbfStatus is RbfStatus.WaitingForSigs -> add(channel.rbfStatus.session.fundingTx) - channel is Normal && channel.spliceStatus is SpliceStatus.WaitingForSigs -> add(channel.spliceStatus.session.fundingTx) + channel is WaitForFundingSigned -> addAll(channel.signingSession.fundingTx.tx.localInputs.map { it.outPoint }) + channel is WaitForFundingConfirmed && channel.rbfStatus is RbfStatus.WaitingForSigs -> addAll(channel.rbfStatus.session.fundingTx.tx.localInputs.map { it.outPoint }) + channel is Normal && channel.spliceStatus is SpliceStatus.WaitingForSigs -> addAll(channel.spliceStatus.session.fundingTx.tx.localInputs.map { it.outPoint }) else -> {} } - // Add all inputs in unconfirmed funding txs (utxos spent by confirmed transactions will never appear in our wallet). + // Add all inputs from previously broadcast funding txs. + // We include confirmed transactions as well, in case our electrum server (and thus our wallet) isn't up-to-date. when (channel) { is ChannelStateWithCommitments -> channel.commitments.all .map { it.localFundingStatus } - .filterIsInstance() - .forEach { add(it.sharedTx) } + .forEach { fundingStatus -> + when (fundingStatus) { + is LocalFundingStatus.UnconfirmedFundingTx -> addAll(fundingStatus.sharedTx.tx.localInputs.map { it.outPoint }) + is LocalFundingStatus.ConfirmedFundingTx -> addAll(fundingStatus.signedTx.txIn.map { it.outPoint }) + } + } else -> {} } } } - val localInputs = unconfirmedFundingTxs.flatMap { fundingTx -> fundingTx.tx.localInputs.map { it.outPoint } } - return localInputs.toSet() } } } \ No newline at end of file From af5b38723b1c0502900abf2179d729e516d54d5d Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 20 Nov 2023 14:54:32 +0100 Subject: [PATCH 2/2] Add test --- .../electrum/SwapInManagerTestsCommon.kt | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt index a92a4f392..56111db03 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/blockchain/electrum/SwapInManagerTestsCommon.kt @@ -4,19 +4,22 @@ import fr.acinq.bitcoin.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.NodeParams import fr.acinq.lightning.SwapInParams +import fr.acinq.lightning.blockchain.BITCOIN_FUNDING_DEPTHOK +import fr.acinq.lightning.blockchain.WatchEventConfirmed +import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.channel.LNChannel import fr.acinq.lightning.channel.LocalFundingStatus import fr.acinq.lightning.channel.TestsHelper +import fr.acinq.lightning.channel.states.Normal import fr.acinq.lightning.channel.states.SpliceTestsCommon import fr.acinq.lightning.channel.states.WaitForFundingSignedTestsCommon import fr.acinq.lightning.tests.utils.LightningTestSuite import fr.acinq.lightning.utils.MDCLogger import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.SpliceLocked import org.kodein.log.LoggerFactory import org.kodein.log.newLogger -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull +import kotlin.test.* class SwapInManagerTestsCommon : LightningTestSuite() { @@ -170,4 +173,28 @@ class SwapInManagerTestsCommon : LightningTestSuite() { } } + @Test + fun `swap funds -- ignore inputs from confirmed splice`() { + val (alice, bob) = TestsHelper.reachNormal(zeroConf = true) + val (alice1, _) = SpliceTestsCommon.spliceIn(alice, bob, listOf(50_000.sat, 75_000.sat)) + assertEquals(2, alice1.commitments.active.size) + assertIs(alice1.commitments.latest.localFundingStatus) + val inputs = (alice1.commitments.latest.localFundingStatus as LocalFundingStatus.UnconfirmedFundingTx).sharedTx.tx.localInputs + assertEquals(2, inputs.size) // 2 splice inputs + val spliceTx = alice1.commitments.latest.localFundingStatus.signedTx!! + val (alice2, _) = alice1.process(ChannelCommand.WatchReceived(WatchEventConfirmed(alice.channelId, BITCOIN_FUNDING_DEPTHOK, 100, 2, spliceTx))) + val (alice3, _) = alice2.process(ChannelCommand.MessageReceived(SpliceLocked(alice.channelId, spliceTx.hash))) + assertIs>(alice3) + assertEquals(1, alice3.commitments.all.size) + assertIs(alice3.commitments.latest.localFundingStatus) + val wallet = run { + val parentTxs = inputs.map { it.previousTx } + val unspent = inputs.map { i -> UnspentItem(i.outPoint.txid, i.outPoint.index.toInt(), i.txOut.amount.toLong(), 100) } + WalletState(mapOf(dummyAddress to unspent), parentTxs.associateBy { it.txid }) + } + val mgr = SwapInManager(listOf(alice3.state), logger) + val cmd = SwapInCommand.TrySwapIn(currentBlockHeight = 150, wallet = wallet, swapInParams = SwapInParams(minConfirmations = 5, maxConfirmations = 720, refundDelay = 900), trustedTxs = emptySet()) + mgr.process(cmd).also { assertNull(it) } + } + } \ No newline at end of file