From e9ed7c68f22caa3baa11fbba55a89be1e43b4494 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 18:59:46 +0200 Subject: [PATCH 1/3] fix: add wallet coins to a release the original coins cannot fund --- .../repositories/OnchainSendAttemptStore.kt | 17 +++++++ .../to/bitkit/services/LightningService.kt | 44 ++++++++++++------- .../OnchainSendAttemptStoreTest.kt | 19 ++++++++ 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 8765120660..f62f4bf3b6 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive +import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis @@ -148,6 +149,22 @@ fun releaseOutbids(receipt: OnchainPreparedReceipt, attempt: OnchainSendAttempt) return fee > attempt.highestCandidateFee } +/** + * The coin sets a release tries, smallest first: the original's coins alone, then with the wallet's largest other + * coin, then with all of them. Each set holds every spendable original coin, so no candidate can confirm, while a + * small payment's coins alone may not cover the release's output and higher fee. + */ +fun releaseInputSets(spendable: List, original: Set): List> { + val (originals, others) = spendable.partition { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original } + if (originals.isEmpty()) return emptyList() + val byValue = others.sortedByDescending { it.valueSats } + return buildList { + add(originals) + byValue.firstOrNull()?.let { add(originals + it) } + if (byValue.size > 1) add(originals + byValue) + } +} + /** The wallet side of cancelling: proving no candidate reached the network, and spending its coins back. */ interface OnchainReleasing { suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index fd21a7d300..6c6bb38816 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -75,6 +75,7 @@ import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.releaseInputSets import to.bitkit.repositories.releaseOutbids import to.bitkit.utils.AppError import to.bitkit.utils.LdkError @@ -1069,25 +1070,34 @@ class LightningService internal constructor( */ suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { val original = attempt.originalInputs.orEmpty().toSet() - val utxos = listSpendableOutputs().getOrThrow().filter { - OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original - } - if (utxos.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() - var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL + val inputSets = releaseInputSets(listSpendableOutputs().getOrThrow(), original) + if (inputSets.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() + val originalCoins = inputSets.first().map { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) }.toSet() val address = newAddress() - repeat(RELEASE_FEE_ATTEMPTS) { - val prepared = prepareOnchainSend( - address = address, - sats = RELEASE_AMOUNT_SATS, - satsPerVByte = feeRate, - utxosToSpend = utxos, - walletIndex = attempt.walletIndex, - ) - check(prepared.receipt.inputs.any { it in original }) - if (releaseOutbids(prepared.receipt, attempt)) return prepared - feeRate *= 2uL + var lastError: Throwable = ServiceError.NodeNotSetup() + for (utxos in inputSets) { + var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL + for (feeAttempt in 0 until RELEASE_FEE_ATTEMPTS) { + val prepared = runSuspendCatching { + prepareOnchainSend( + address = address, + sats = RELEASE_AMOUNT_SATS, + satsPerVByte = feeRate, + utxosToSpend = utxos, + walletIndex = attempt.walletIndex, + ) + }.getOrElse { + // These coins cannot fund the release at this fee; try the next, larger set. + lastError = it + null + } ?: break + // Every spendable original coin must go, so no candidate can still confirm. + check(prepared.receipt.inputs.containsAll(originalCoins)) + if (releaseOutbids(prepared.receipt, attempt)) return prepared + feeRate *= 2uL + } } - throw ServiceError.NodeNotSetup() + throw lastError } internal suspend fun callOnchainSend(block: suspend () -> T): T = diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 5a2097c0fc..a42bd88a0e 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.test.runCurrent import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -34,6 +36,23 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { @get:Rule val temporaryFolder = TemporaryFolder() + @Test + fun `release adds wallet coins when the original coins fall short`() { + fun utxo(byte: String, value: ULong) = SpendableUtxo(OutPoint(byte.repeat(32), 0u), value) + val original = utxo("11", 1_200uL) + val small = utxo("22", 5_000uL) + val large = utxo("33", 90_000uL) + val originalInput = setOf(OnchainSendInput("11".repeat(32), 0u)) + + val sets = releaseInputSets(listOf(small, original, large), originalInput) + + assertEquals( + listOf(listOf(1_200uL), listOf(1_200uL, 90_000uL), listOf(1_200uL, 90_000uL, 5_000uL)), + sets.map { set -> set.map { it.valueSats } }, + ) + assertTrue(releaseInputSets(listOf(small, large), originalInput).isEmpty(), "A release needs an original coin") + } + @Test fun `prepared candidate fees survive disk reopen without promoting unknown outcomes`() = test { val file = temporaryFolder.newFile() From fac146426e72395a31d30d125e1a3749a5e5617d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 17 Oct 2026 19:38:30 +0200 Subject: [PATCH 2/3] fix: add only confirmed wallet coins to a release --- .../to/bitkit/services/LightningService.kt | 15 +++- .../bitkit/services/LightningServiceTest.kt | 68 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 6c6bb38816..6b20ee227c 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -30,6 +30,7 @@ import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Config +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.ElectrumSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate @@ -42,6 +43,7 @@ import org.lightningdevkit.ldknode.OnchainWalletAccount import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId +import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PeerDetails import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.ScoringFeeParameters @@ -1070,14 +1072,23 @@ class LightningService internal constructor( */ suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend { val original = attempt.originalInputs.orEmpty().toSet() - val inputSets = releaseInputSets(listSpendableOutputs().getOrThrow(), original) + // Only confirmed coins join the original's: nodes holding a candidate refuse a replacement that adds an + // unconfirmed input (BIP125 rule 2), and an unconfirmed parent can still be replaced or evicted. + val confirmedTxids = listPayments().orEmpty().mapNotNull { payment -> + (payment.kind as? PaymentKind.Onchain)?.takeIf { it.status is ConfirmationStatus.Confirmed }?.txid + }.toSet() + val spendable = listSpendableOutputs().getOrThrow().filter { + OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original || it.outpoint.txid in confirmedTxids + } + val inputSets = releaseInputSets(spendable, original) if (inputSets.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() val originalCoins = inputSets.first().map { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) }.toSet() val address = newAddress() var lastError: Throwable = ServiceError.NodeNotSetup() for (utxos in inputSets) { var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL - for (feeAttempt in 0 until RELEASE_FEE_ATTEMPTS) { + var feeAttemptsLeft = RELEASE_FEE_ATTEMPTS + while (feeAttemptsLeft-- > 0) { val prepared = runSuspendCatching { prepareOnchainSend( address = address, diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 63934ce38c..db585aedfb 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -747,4 +747,72 @@ class LightningServiceTest : BaseUnitTest() { isTrackingEnabled = enabled, setupState = WatchOnlyAccountSetupState.Active, ) + + @Test + fun `release never adds an unconfirmed wallet coin`() = test { + val originalTxid = "11".repeat(32) + val unconfirmedTxid = "33".repeat(32) + val original = org.lightningdevkit.ldknode.SpendableUtxo( + org.lightningdevkit.ldknode.OutPoint(originalTxid, 0u), 1_200uL, + ) + val unconfirmed = org.lightningdevkit.ldknode.SpendableUtxo( + org.lightningdevkit.ldknode.OutPoint(unconfirmedTxid, 0u), 90_000uL, + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.listSpendableOutputs()).thenReturn(listOf(original, unconfirmed)) + whenever(onchainPayment.newAddress()).thenReturn("bcrt1qrelease") + whenever(node.listPayments()).thenReturn( + listOf( + org.lightningdevkit.ldknode.PaymentDetails( + id = "incoming", + kind = org.lightningdevkit.ldknode.PaymentKind.Onchain( + unconfirmedTxid, + org.lightningdevkit.ldknode.ConfirmationStatus.Unconfirmed, + ), + amountMsat = 90_000_000uL, + feePaidMsat = 0uL, + direction = org.lightningdevkit.ldknode.PaymentDirection.INBOUND, + status = org.lightningdevkit.ldknode.PaymentStatus.PENDING, + latestUpdateTimestamp = 100uL, + ), + ), + ) + val release = mock().also { + whenever(it.txid()).thenReturn("44".repeat(32)) + whenever(it.inputs()).thenReturn(listOf(original.outpoint, unconfirmed.outpoint)) + whenever(it.recipientAmountSats()).thenReturn(1_000uL) + whenever(it.miningFeeSats()).thenReturn(10_000uL) + } + // The original's coin alone cannot fund the release; any set with the extra coin can. + whenever(onchainPayment.prepareSendToAddress(any(), any(), any(), anyOrNull())).thenAnswer { + val utxos = it.getArgument?>(3).orEmpty() + if (utxos.any { u -> u.outpoint.txid == unconfirmedTxid }) release else throw ServiceError.NodeNotSetup() + } + val attempt = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt", + requestId = null, + orderId = null, + address = "bcrt1qpayee", + amountSats = 600uL, + isMaxAmount = false, + feeRateSatsPerVByte = 10uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + txid = "55".repeat(32), + originalInputs = listOf(to.bitkit.repositories.OnchainSendInput(originalTxid, 0u)), + candidateTxids = listOf("55".repeat(32)), + candidateFeeRates = mapOf("55".repeat(32) to 10uL), + candidateMiningFees = mapOf("55".repeat(32) to 1_410uL), + ) + + val prepared = runCatching { sut.prepareRelease(attempt) }.getOrNull() + + val spent = prepared?.receipt?.inputs.orEmpty().map { it.txid } + assertFalse( + unconfirmedTxid in spent, + "Release spends unconfirmed coin '$unconfirmedTxid'; nodes holding a candidate reject it (BIP125 rule 2)", + ) + } } From f49724063ab53526eff606678a55bde098725a46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 18 Oct 2026 06:40:21 +0200 Subject: [PATCH 3/3] fix: grow a release's wallet coins one at a time --- .../repositories/OnchainSendAttemptStore.kt | 13 +- .../OnchainSendAttemptStoreTest.kt | 10 +- .../bitkit/services/LightningServiceTest.kt | 174 ++++++++++++------ 3 files changed, 132 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index f62f4bf3b6..0b2a6a0d0a 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -150,19 +150,16 @@ fun releaseOutbids(receipt: OnchainPreparedReceipt, attempt: OnchainSendAttempt) } /** - * The coin sets a release tries, smallest first: the original's coins alone, then with the wallet's largest other - * coin, then with all of them. Each set holds every spendable original coin, so no candidate can confirm, while a - * small payment's coins alone may not cover the release's output and higher fee. + * The coin sets a release tries, smallest first: the original's coins alone, then with the wallet's other coins added + * one at a time, largest first. Each set holds every spendable original coin, so no candidate can confirm, while a + * small payment's coins alone may not cover the release's output and higher fee. Growing one coin at a time stops at + * the first set that can pay, so dust coins join only when nothing smaller would. */ fun releaseInputSets(spendable: List, original: Set): List> { val (originals, others) = spendable.partition { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) in original } if (originals.isEmpty()) return emptyList() val byValue = others.sortedByDescending { it.valueSats } - return buildList { - add(originals) - byValue.firstOrNull()?.let { add(originals + it) } - if (byValue.size > 1) add(originals + byValue) - } + return (0..byValue.size).map { originals + byValue.take(it) } } /** The wallet side of cancelling: proving no candidate reached the network, and spending its coins back. */ diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index a42bd88a0e..ae9314b696 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -42,12 +42,18 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val original = utxo("11", 1_200uL) val small = utxo("22", 5_000uL) val large = utxo("33", 90_000uL) + val medium = utxo("44", 20_000uL) val originalInput = setOf(OnchainSendInput("11".repeat(32), 0u)) - val sets = releaseInputSets(listOf(small, original, large), originalInput) + val sets = releaseInputSets(listOf(small, original, large, medium), originalInput) assertEquals( - listOf(listOf(1_200uL), listOf(1_200uL, 90_000uL), listOf(1_200uL, 90_000uL, 5_000uL)), + listOf( + listOf(1_200uL), + listOf(1_200uL, 90_000uL), + listOf(1_200uL, 90_000uL, 20_000uL), + listOf(1_200uL, 90_000uL, 20_000uL, 5_000uL), + ), sets.map { set -> set.map { it.valueSats } }, ) assertTrue(releaseInputSets(listOf(small, large), originalInput).isEmpty(), "A release needs an original coin") diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index db585aedfb..ca51cf9ec9 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressInfo import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.ConfirmationStatus import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.KeychainKind @@ -26,6 +27,12 @@ import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainPayment import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer @@ -48,6 +55,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendAttemptStore @@ -750,69 +758,125 @@ class LightningServiceTest : BaseUnitTest() { @Test fun `release never adds an unconfirmed wallet coin`() = test { - val originalTxid = "11".repeat(32) - val unconfirmedTxid = "33".repeat(32) - val original = org.lightningdevkit.ldknode.SpendableUtxo( - org.lightningdevkit.ldknode.OutPoint(originalTxid, 0u), 1_200uL, + val original = releaseCoin("11", 1_200uL) + val unconfirmed = releaseCoin("33", 90_000uL) + // The original's coin alone cannot fund the release; any set with the extra coin can. + stubRelease( + coins = listOf(original, unconfirmed), + confirmedTxids = emptySet(), + funds = { utxos -> unconfirmed in utxos }, + ) + + val prepared = runCatching { sut.prepareRelease(releaseAttempt(original)) }.getOrNull() + + val spent = prepared?.receipt?.inputs.orEmpty().map { it.txid } + assertFalse( + unconfirmed.outpoint.txid in spent, + "Release spends an unconfirmed coin; nodes holding a candidate reject it (BIP125 rule 2)", + ) + } + + @Test + fun `release adds confirmed wallet coins largest first until they fund it`() = test { + val original = releaseCoin("11", 1_200uL) + val large = releaseCoin("22", 50_000uL) + val medium = releaseCoin("33", 40_000uL) + val dust = releaseCoin("44", 600uL) + val coins = listOf(dust, original, medium, large) + stubRelease( + coins = coins, + confirmedTxids = coins.map { it.outpoint.txid }.toSet(), + funds = { utxos -> utxos.sumOf { it.valueSats } >= 80_000uL }, ) - val unconfirmed = org.lightningdevkit.ldknode.SpendableUtxo( - org.lightningdevkit.ldknode.OutPoint(unconfirmedTxid, 0u), 90_000uL, + + val prepared = sut.prepareRelease(releaseAttempt(original)) + + // The original's coin alone and with the largest coin both fall short; the next set pays without the dust. + assertEquals(listOf(original, large, medium).map { it.outpoint.txid }, prepared.receipt.inputs.map { it.txid }) + assertTrue(requireNotNull(prepared.receipt.miningFeeSats) > RELEASE_CANDIDATE_FEE) + } + + @Test + fun `release falls back to every confirmed wallet coin`() = test { + val original = releaseCoin("11", 1_200uL) + val large = releaseCoin("22", 50_000uL) + val small = releaseCoin("33", 5_000uL) + val coins = listOf(original, small, large) + stubRelease( + coins = coins, + confirmedTxids = coins.map { it.outpoint.txid }.toSet(), + funds = { utxos -> utxos.size == coins.size }, ) + + val prepared = sut.prepareRelease(releaseAttempt(original)) + + assertEquals(listOf(original, large, small).map { it.outpoint.txid }, prepared.receipt.inputs.map { it.txid }) + } + + private fun releaseCoin(byte: String, valueSats: ULong) = SpendableUtxo(OutPoint(byte.repeat(32), 0u), valueSats) + + /** Prepares a release from the coins it is handed, failing like a short wallet unless [funds] says they pay. */ + private fun stubRelease( + coins: List, + confirmedTxids: Set, + funds: (List) -> Boolean, + ) { whenever(node.onchainPayment()).thenReturn(onchainPayment) - whenever(onchainPayment.listSpendableOutputs()).thenReturn(listOf(original, unconfirmed)) + whenever(onchainPayment.listSpendableOutputs()).thenReturn(coins) whenever(onchainPayment.newAddress()).thenReturn("bcrt1qrelease") whenever(node.listPayments()).thenReturn( - listOf( - org.lightningdevkit.ldknode.PaymentDetails( - id = "incoming", - kind = org.lightningdevkit.ldknode.PaymentKind.Onchain( - unconfirmedTxid, - org.lightningdevkit.ldknode.ConfirmationStatus.Unconfirmed, - ), - amountMsat = 90_000_000uL, - feePaidMsat = 0uL, - direction = org.lightningdevkit.ldknode.PaymentDirection.INBOUND, - status = org.lightningdevkit.ldknode.PaymentStatus.PENDING, - latestUpdateTimestamp = 100uL, - ), - ), + coins.map { onchainPaymentDetails(it, confirmed = it.outpoint.txid in confirmedTxids) }, ) - val release = mock().also { - whenever(it.txid()).thenReturn("44".repeat(32)) - whenever(it.inputs()).thenReturn(listOf(original.outpoint, unconfirmed.outpoint)) - whenever(it.recipientAmountSats()).thenReturn(1_000uL) - whenever(it.miningFeeSats()).thenReturn(10_000uL) - } - // The original's coin alone cannot fund the release; any set with the extra coin can. - whenever(onchainPayment.prepareSendToAddress(any(), any(), any(), anyOrNull())).thenAnswer { - val utxos = it.getArgument?>(3).orEmpty() - if (utxos.any { u -> u.outpoint.txid == unconfirmedTxid }) release else throw ServiceError.NodeNotSetup() + whenever(onchainPayment.prepareSendToAddress(any(), any(), any(), anyOrNull())).thenAnswer { call -> + val utxos = call.getArgument?>(3).orEmpty() + if (!funds(utxos)) throw ServiceError.NodeNotSetup() + mock().also { + whenever(it.txid()).thenReturn("66".repeat(32)) + whenever(it.inputs()).thenReturn(utxos.map { utxo -> utxo.outpoint }) + whenever(it.recipientAmountSats()).thenReturn(1_000uL) + whenever(it.miningFeeSats()).thenReturn(RELEASE_CANDIDATE_FEE + 1_000uL) + } } - val attempt = OnchainSendAttempt( - walletId = WalletScope.default, - attemptId = "attempt", - requestId = null, - orderId = null, - address = "bcrt1qpayee", - amountSats = 600uL, - isMaxAmount = false, - feeRateSatsPerVByte = 10uL, - isTransfer = false, - channelId = null, - tags = emptyList(), - txid = "55".repeat(32), - originalInputs = listOf(to.bitkit.repositories.OnchainSendInput(originalTxid, 0u)), - candidateTxids = listOf("55".repeat(32)), - candidateFeeRates = mapOf("55".repeat(32) to 10uL), - candidateMiningFees = mapOf("55".repeat(32) to 1_410uL), - ) + } - val prepared = runCatching { sut.prepareRelease(attempt) }.getOrNull() + private fun onchainPaymentDetails(coin: SpendableUtxo, confirmed: Boolean) = PaymentDetails( + id = coin.outpoint.txid, + kind = PaymentKind.Onchain( + coin.outpoint.txid, + if (confirmed) { + ConfirmationStatus.Confirmed(blockHash = "00".repeat(32), height = 100u, timestamp = 100uL) + } else { + ConfirmationStatus.Unconfirmed + }, + ), + amountMsat = coin.valueSats * 1_000uL, + feePaidMsat = 0uL, + direction = PaymentDirection.INBOUND, + status = if (confirmed) PaymentStatus.SUCCEEDED else PaymentStatus.PENDING, + latestUpdateTimestamp = 100uL, + ) - val spent = prepared?.receipt?.inputs.orEmpty().map { it.txid } - assertFalse( - unconfirmedTxid in spent, - "Release spends unconfirmed coin '$unconfirmedTxid'; nodes holding a candidate reject it (BIP125 rule 2)", - ) + private fun releaseAttempt(original: SpendableUtxo) = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt", + requestId = null, + orderId = null, + address = "bcrt1qpayee", + amountSats = 600uL, + isMaxAmount = false, + feeRateSatsPerVByte = 10uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + txid = RELEASE_CANDIDATE, + originalInputs = listOf(OnchainSendInput(original.outpoint.txid, original.outpoint.vout)), + candidateTxids = listOf(RELEASE_CANDIDATE), + candidateFeeRates = mapOf(RELEASE_CANDIDATE to 10uL), + candidateMiningFees = mapOf(RELEASE_CANDIDATE to RELEASE_CANDIDATE_FEE), + ) + + private companion object { + val RELEASE_CANDIDATE = "55".repeat(32) + const val RELEASE_CANDIDATE_FEE = 1_410uL } }