Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
45 changes: 42 additions & 3 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package to.bitkit.repositories

import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.BtOrderState2
import com.synonym.bitkitcore.CJitStateEnum
import com.synonym.bitkitcore.ChannelLiquidityOptions
import com.synonym.bitkitcore.ChannelLiquidityParams
import com.synonym.bitkitcore.CreateCjitOptions
Expand All @@ -18,10 +19,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 +44,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 +129,41 @@ 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

cachedMatch()?.let { return@withContext it }

// A ChannelReady can only be a CJIT if a live cached entry is still awaiting its channel; otherwise skip
// the server round-trip so a non-CJIT transfer confirmation isn't delayed by a slow Blocktank API.
val hasPendingCjit = _blocktankState.value.cjitEntries.any {
it.channel == null && it.state != CJitStateEnum.EXPIRED && it.state != CJitStateEnum.FAILED
}
if (!hasPendingCjit) return@withContext null
Comment thread
jvsena42 marked this conversation as resolved.
Outdated

refreshCjitEntries()
return@withContext cachedMatch()
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}

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 +589,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
177 changes: 177 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,26 @@
package to.bitkit.repositories

import app.cash.turbine.test
import com.synonym.bitkitcore.CJitStateEnum
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 +199,174 @@ 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(staleEntry.state).thenReturn(CJitStateEnum.CREATED)
seedCjitEntries(staleEntry)
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()
seedCjitEntries(pendingCjitEntry())
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()
seedCjitEntries(pendingCjitEntry())
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 does not refresh when no cached CJIT entry is awaiting a channel`() = test {
sut = createSut()
// Only an already-associated entry is cached (none awaiting a channel), so this ChannelReady cannot be a
// CJIT: the server must not be hit even though a refresh would return a matching entry.
val associatedChannel = mock<IBtChannel>()
whenever(associatedChannel.fundingTx).thenReturn(FundingTx(id = "other-funding-tx", vout = 0u))
val associatedEntry = mock<IcJitEntry>()
whenever(associatedEntry.channel).thenReturn(associatedChannel)
seedCjitEntries(associatedEntry)

val fundingTxId = "channel-order-funding-tx"
val matchingChannel = mock<IBtChannel>()
whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u))
val matchingEntry = mock<IcJitEntry>()
whenever(matchingEntry.channel).thenReturn(matchingChannel)
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(matchingEntry))

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

assertNull(sut.getCjitEntry(channelDetails))
}

@Test
fun `getCjitEntry does not refresh for an expired CJIT entry awaiting no channel`() = test {
sut = createSut()
// An expired entry has no channel but can never open one, so it must not trigger a server refresh
// even though a refresh would surface a matching entry.
val expiredEntry = mock<IcJitEntry>()
whenever(expiredEntry.channel).thenReturn(null)
whenever(expiredEntry.state).thenReturn(CJitStateEnum.EXPIRED)
seedCjitEntries(expiredEntry)

val fundingTxId = "channel-order-funding-tx"
val matchingChannel = mock<IBtChannel>()
whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u))
val matchingEntry = mock<IcJitEntry>()
whenever(matchingEntry.channel).thenReturn(matchingChannel)
whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(matchingEntry))

val channelDetails = mock<ChannelDetails>()
whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, 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)
seedCjitEntries(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 a pending CJIT is awaiting but refresh fails`() = test {
sut = createSut()
seedCjitEntries(pendingCjitEntry())
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()
seedCjitEntries(pendingCjitEntry())
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))
}

private fun pendingCjitEntry(): IcJitEntry = mock<IcJitEntry>().apply {
whenever(channel).thenReturn(null)
whenever(state).thenReturn(CJitStateEnum.CREATED)
}

private suspend fun seedCjitEntries(vararg entries: IcJitEntry) {
sut.restoreFromBackup(
BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = entries.toList()),
)
}
}
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