Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SpendableUtxo>, original: Set<OnchainSendInput>): List<List<SpendableUtxo>> {
Comment thread
ovitrif marked this conversation as resolved.
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
Expand Down
53 changes: 37 additions & 16 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <T> callOnchainSend(block: suspend () -> T): T =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
132 changes: 132 additions & 0 deletions app/src/test/java/to/bitkit/services/LightningServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -747,4 +755,128 @@ class LightningServiceTest : BaseUnitTest() {
isTrackingEnabled = enabled,
setupState = WatchOnlyAccountSetupState.Active,
)

@Test
fun `release never adds an unconfirmed wallet coin`() = test {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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<SpendableUtxo>,
confirmedTxids: Set<String>,
funds: (List<SpendableUtxo>) -> 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<List<SpendableUtxo>?>(3).orEmpty()
if (!funds(utxos)) throw ServiceError.NodeNotSetup()
mock<org.lightningdevkit.ldknode.PreparedOnchainSend>().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
}
}
Loading