diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 8765120660..0b2a6a0d0a 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,19 @@ 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 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 (0..byValue.size).map { originals + byValue.take(it) } +} + /** 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..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 @@ -75,6 +77,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 +1072,43 @@ 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 + // 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 } - if (utxos.isEmpty() || attempt.walletIndex != currentWalletIndex) throw ServiceError.NodeNotSetup() - var feeRate = maxOf(attempt.feeRateSatsPerVByte, attempt.candidateFeeRates.values.maxOrNull() ?: 0uL) + 2uL + 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() - 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 + var feeAttemptsLeft = RELEASE_FEE_ATTEMPTS + while (feeAttemptsLeft-- > 0) { + 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..ae9314b696 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,29 @@ 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 medium = utxo("44", 20_000uL) + val originalInput = setOf(OnchainSendInput("11".repeat(32), 0u)) + + val sets = releaseInputSets(listOf(small, original, large, medium), originalInput) + + assertEquals( + 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") + } + @Test fun `prepared candidate fees survive disk reopen without promoting unknown outcomes`() = test { val file = temporaryFolder.newFile() diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 63934ce38c..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 @@ -747,4 +755,128 @@ class LightningServiceTest : BaseUnitTest() { isTrackingEnabled = enabled, setupState = WatchOnlyAccountSetupState.Active, ) + + @Test + fun `release never adds an unconfirmed wallet coin`() = test { + 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 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(coins) + whenever(onchainPayment.newAddress()).thenReturn("bcrt1qrelease") + whenever(node.listPayments()).thenReturn( + coins.map { onchainPaymentDetails(it, confirmed = it.outpoint.txid in confirmedTxids) }, + ) + 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) + } + } + } + + 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, + ) + + 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 + } }