Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
38 changes: 35 additions & 3 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import com.synonym.bitkitcore.giftPay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
Expand All @@ -41,6 +43,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Bolt11Invoice
import org.lightningdevkit.ldknode.ChannelDetails
Expand Down Expand Up @@ -125,9 +128,35 @@ class BlocktankRepo @Inject constructor(
}

suspend fun getCjitEntry(channel: ChannelDetails): IcJitEntry? = withContext(bgDispatcher) {
return@withContext _blocktankState.value.cjitEntries.firstOrNull { order ->
order.channelSizeSat == channel.channelValueSats &&
order.lspNode.pubkey == channel.counterpartyNodeId
val fundingTxId = channel.fundingTxo?.txid ?: return@withContext null

fun cachedMatch(): IcJitEntry? = _blocktankState.value.cjitEntries
.firstOrNull { it.channel?.fundingTx?.id == fundingTxId }
Comment thread
jvsena42 marked this conversation as resolved.
Outdated

// Use cached state first; only refresh from the server when the freshly opened channel isn't associated yet.
return@withContext cachedMatch() ?: run {
refreshCjitEntries()
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
cachedMatch()
}
}

private suspend fun refreshCjitEntries() {
repeat(CJIT_REFRESH_ATTEMPTS) { attempt ->
runCatching {
withTimeout(CJIT_REFRESH_TIMEOUT) {
coreService.blocktank.cjitEntries(refresh = true)
}
}.onSuccess { entries ->
_blocktankState.update { it.copy(cjitEntries = entries.toImmutableList()) }
return
}.onFailure {
if (it is CancellationException && it !is TimeoutCancellationException) throw it
Comment thread
jvsena42 marked this conversation as resolved.
if (attempt == CJIT_REFRESH_ATTEMPTS - 1) {
Logger.warn("Failed to refresh CJIT entries; using cached state", it, context = TAG)
return
}
}
delay(CJIT_REFRESH_RETRY_DELAY)
}
}

Expand Down Expand Up @@ -553,6 +582,9 @@ class BlocktankRepo @Inject constructor(
private const val PEER_CONNECTION_DELAY_MS = 2_000L
private val TIMEOUT_GIFT_CODE = 30.seconds
private val GIFT_PAYMENT_RECEIVE_TIMEOUT = 45.seconds
private const val CJIT_REFRESH_ATTEMPTS = 3
private val CJIT_REFRESH_TIMEOUT = 5.seconds
private val CJIT_REFRESH_RETRY_DELAY = 1.seconds
}
}

Expand Down
115 changes: 115 additions & 0 deletions app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package to.bitkit.repositories

import app.cash.turbine.test
import com.synonym.bitkitcore.FundingTx
import com.synonym.bitkitcore.IBtChannel
import com.synonym.bitkitcore.IBtInfo
import com.synonym.bitkitcore.IBtOrder
import com.synonym.bitkitcore.IcJitEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Before
import org.junit.Test
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.OutPoint
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.mockito.kotlin.wheneverBlocking
import to.bitkit.data.AppCacheData
import to.bitkit.data.CacheStore
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.services.CoreService
import to.bitkit.services.LightningService
import to.bitkit.test.BaseUnitTest
Expand Down Expand Up @@ -192,4 +198,113 @@ class BlocktankRepoTest : BaseUnitTest() {
assertTrue(result.isFailure)
}
}

@Test
fun `getCjitEntry returns null when channel has no funding txo`() = test {
sut = createSut()
val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(null)

assertNull(sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry does not match a stale unpaid CJIT entry without an opened channel`() = test {
sut = createSut()
// A leftover CJIT entry that was never paid: same size & LSP as a transfer-flow channel order,
// but it never opened a channel. It must not be mistaken for the freshly opened channel.
val staleEntry = mock<IcJitEntry>()
whenever(staleEntry.channel).thenReturn(null)
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(staleEntry))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = "channel-order-funding-tx", vout = 0u))

assertNull(sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry matches the entry whose channel funding tx matches`() = test {
sut = createSut()
val fundingTxId = "cjit-funding-tx"
val matchingChannel = mock<IBtChannel>()
whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u))
val otherChannel = mock<IBtChannel>()
whenever(otherChannel.fundingTx).thenReturn(FundingTx(id = "other-funding-tx", vout = 0u))
val matchingEntry = mock<IcJitEntry>()
whenever(matchingEntry.channel).thenReturn(matchingChannel)
val otherEntry = mock<IcJitEntry>()
whenever(otherEntry.channel).thenReturn(otherChannel)
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(otherEntry, matchingEntry))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u))

assertEquals(matchingEntry, sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry returns null when no CJIT channel funding tx matches`() = test {
sut = createSut()
val channel = mock<IBtChannel>()
whenever(channel.fundingTx).thenReturn(FundingTx(id = "cjit-funding-tx", vout = 0u))
val entry = mock<IcJitEntry>()
whenever(entry.channel).thenReturn(channel)
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(entry))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = "different-funding-tx", vout = 0u))

assertNull(sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry returns cached entry without refreshing when already associated`() = test {
sut = createSut()
val fundingTxId = "cached-funding-tx"
val channel = mock<IBtChannel>()
whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u))
val cachedEntry = mock<IcJitEntry>()
whenever(cachedEntry.channel).thenReturn(channel)

sut.restoreFromBackup(
BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = listOf(cachedEntry)),
)

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u))

// A server refresh would return no entries (setUp default), so a non-null result can only come
// from the cached state short-circuit, proving the server is not hit when the entry is already known.
assertEquals(cachedEntry, sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry returns null when cache misses and refresh fails`() = test {
sut = createSut()
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenThrow(RuntimeException("Network error"))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = "missing-funding-tx", vout = 0u))

assertNull(sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry retries the refresh and matches after a transient failure`() = test {
sut = createSut()
val fundingTxId = "cjit-funding-tx"
val channel = mock<IBtChannel>()
whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u))
val entry = mock<IcJitEntry>()
whenever(entry.channel).thenReturn(channel)
whenever(coreService.blocktank.cjitEntries(refresh = true))
.thenThrow(RuntimeException("transient"))
.thenReturn(listOf(entry))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u))

assertEquals(entry, sut.getCjitEntry(channelDetails))
}
}
1 change: 1 addition & 0 deletions changelog.d/next/1017.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Transferring to your spending balance now reliably shows the "Spending Balance Ready" confirmation instead of sometimes being mistaken for an incoming payment when an unused instant-payment invoice is still pending.
Loading