From 6efa8fe0d98fc3903cfe312b1737f535f35c3888 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 06:56:25 -0300 Subject: [PATCH 01/12] fix: match cjit entry to channel by funding tx --- .../to/bitkit/repositories/BlocktankRepo.kt | 11 ++-- .../bitkit/repositories/BlocktankRepoTest.kt | 64 +++++++++++++++++++ changelog.d/next/1016.fixed.md | 1 + 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/1016.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 6cfc2f2d34..cd35321bb9 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -125,10 +125,13 @@ 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 + + // Refresh from the server so a freshly opened CJIT channel association is up to date before matching. + val entries = runCatching { coreService.blocktank.cjitEntries(refresh = true) } + .getOrElse { _blocktankState.value.cjitEntries } + + return@withContext entries.firstOrNull { it.channel?.fundingTx?.id == fundingTxId } } suspend fun refreshInfo() = withContext(bgDispatcher) { diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 7a1448d597..4bfd67da77 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -1,12 +1,17 @@ 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 @@ -192,4 +197,63 @@ class BlocktankRepoTest : BaseUnitTest() { assertTrue(result.isFailure) } } + + @Test + fun `getCjitEntry returns null when channel has no funding txo`() = test { + sut = createSut() + val channelDetails = mock() + 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() + whenever(staleEntry.channel).thenReturn(null) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(staleEntry)) + + val channelDetails = mock() + 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() + whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val otherChannel = mock() + whenever(otherChannel.fundingTx).thenReturn(FundingTx(id = "other-funding-tx", vout = 0u)) + val matchingEntry = mock() + whenever(matchingEntry.channel).thenReturn(matchingChannel) + val otherEntry = mock() + whenever(otherEntry.channel).thenReturn(otherChannel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(otherEntry, matchingEntry)) + + val channelDetails = mock() + 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() + whenever(channel.fundingTx).thenReturn(FundingTx(id = "cjit-funding-tx", vout = 0u)) + val entry = mock() + whenever(entry.channel).thenReturn(channel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(entry)) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = "different-funding-tx", vout = 0u)) + + assertNull(sut.getCjitEntry(channelDetails)) + } } diff --git a/changelog.d/next/1016.fixed.md b/changelog.d/next/1016.fixed.md new file mode 100644 index 0000000000..daa20698fc --- /dev/null +++ b/changelog.d/next/1016.fixed.md @@ -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. From 2e8ac3be0746dac21f19dc6297f8977fec3b1900 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 16 Jun 2026 07:00:19 -0300 Subject: [PATCH 02/12] chore: rename changelog fragment --- changelog.d/next/{1016.fixed.md => 1017.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1016.fixed.md => 1017.fixed.md} (100%) diff --git a/changelog.d/next/1016.fixed.md b/changelog.d/next/1017.fixed.md similarity index 100% rename from changelog.d/next/1016.fixed.md rename to changelog.d/next/1017.fixed.md From ae7baa7b7d6676966c0db12ed05e967f76ba1f63 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 10:15:00 -0300 Subject: [PATCH 03/12] fix: bound and log cjit refresh fallback --- .../to/bitkit/repositories/BlocktankRepo.kt | 15 +++++++++++-- .../bitkit/repositories/BlocktankRepoTest.kt | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index cd35321bb9..e702093443 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -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 @@ -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 @@ -128,8 +131,15 @@ class BlocktankRepo @Inject constructor( val fundingTxId = channel.fundingTxo?.txid ?: return@withContext null // Refresh from the server so a freshly opened CJIT channel association is up to date before matching. - val entries = runCatching { coreService.blocktank.cjitEntries(refresh = true) } - .getOrElse { _blocktankState.value.cjitEntries } + val entries = runCatching { + withTimeout(CJIT_REFRESH_TIMEOUT) { + coreService.blocktank.cjitEntries(refresh = true) + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + Logger.warn("Failed to refresh CJIT entries; using cached state", it, context = TAG) + _blocktankState.value.cjitEntries + } return@withContext entries.firstOrNull { it.channel?.fundingTx?.id == fundingTxId } } @@ -556,6 +566,7 @@ 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 val CJIT_REFRESH_TIMEOUT = 5.seconds } } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 4bfd67da77..21ca50c82d 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -19,6 +19,7 @@ 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 @@ -256,4 +257,25 @@ class BlocktankRepoTest : BaseUnitTest() { assertNull(sut.getCjitEntry(channelDetails)) } + + @Test + fun `getCjitEntry falls back to cached entries when refresh fails`() = test { + sut = createSut() + val fundingTxId = "cached-funding-tx" + val channel = mock() + whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val cachedEntry = mock() + whenever(cachedEntry.channel).thenReturn(channel) + + sut.restoreFromBackup( + BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = listOf(cachedEntry)), + ) + + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenThrow(RuntimeException("Network error")) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u)) + + assertEquals(cachedEntry, sut.getCjitEntry(channelDetails)) + } } From a884da44599c0199caa2738b14e3bd29f8f87994 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 10:41:29 -0300 Subject: [PATCH 04/12] fix: match cjit from cached state, refresh on miss --- .../to/bitkit/repositories/BlocktankRepo.kt | 38 ++++++++++++++----- .../bitkit/repositories/BlocktankRepoTest.kt | 33 +++++++++++++++- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index e702093443..288848d9c9 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -130,18 +130,34 @@ class BlocktankRepo @Inject constructor( suspend fun getCjitEntry(channel: ChannelDetails): IcJitEntry? = withContext(bgDispatcher) { val fundingTxId = channel.fundingTxo?.txid ?: return@withContext null - // Refresh from the server so a freshly opened CJIT channel association is up to date before matching. - val entries = runCatching { - withTimeout(CJIT_REFRESH_TIMEOUT) { - coreService.blocktank.cjitEntries(refresh = true) - } - }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it - Logger.warn("Failed to refresh CJIT entries; using cached state", it, context = TAG) - _blocktankState.value.cjitEntries + fun cachedMatch(): IcJitEntry? = _blocktankState.value.cjitEntries + .firstOrNull { it.channel?.fundingTx?.id == fundingTxId } + + // Use cached state first; only refresh from the server when the freshly opened channel isn't associated yet. + return@withContext cachedMatch() ?: run { + refreshCjitEntries() + cachedMatch() } + } - return@withContext entries.firstOrNull { it.channel?.fundingTx?.id == fundingTxId } + 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 + 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) + } } suspend fun refreshInfo() = withContext(bgDispatcher) { @@ -566,7 +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 } } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 21ca50c82d..e2aa5d9913 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -259,7 +259,7 @@ class BlocktankRepoTest : BaseUnitTest() { } @Test - fun `getCjitEntry falls back to cached entries when refresh fails`() = test { + fun `getCjitEntry returns cached entry without refreshing when already associated`() = test { sut = createSut() val fundingTxId = "cached-funding-tx" val channel = mock() @@ -271,11 +271,40 @@ class BlocktankRepoTest : BaseUnitTest() { BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = listOf(cachedEntry)), ) + val channelDetails = mock() + 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() + 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() + whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val entry = mock() + whenever(entry.channel).thenReturn(channel) + whenever(coreService.blocktank.cjitEntries(refresh = true)) + .thenThrow(RuntimeException("transient")) + .thenReturn(listOf(entry)) + val channelDetails = mock() whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u)) - assertEquals(cachedEntry, sut.getCjitEntry(channelDetails)) + assertEquals(entry, sut.getCjitEntry(channelDetails)) } } From 85d27e6d7525835ba3efdd7cb33bc30532bd68c8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 11:41:59 -0300 Subject: [PATCH 05/12] fix: guard cjit refresh to non-terminal pending entries --- .../to/bitkit/repositories/BlocktankRepo.kt | 15 ++-- .../bitkit/repositories/BlocktankRepoTest.kt | 72 +++++++++++++++++-- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 288848d9c9..6086e46646 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -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 @@ -133,11 +134,17 @@ class BlocktankRepo @Inject constructor( fun cachedMatch(): IcJitEntry? = _blocktankState.value.cjitEntries .firstOrNull { it.channel?.fundingTx?.id == fundingTxId } - // Use cached state first; only refresh from the server when the freshly opened channel isn't associated yet. - return@withContext cachedMatch() ?: run { - refreshCjitEntries() - cachedMatch() + 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 + + refreshCjitEntries() + return@withContext cachedMatch() } private suspend fun refreshCjitEntries() { diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index e2aa5d9913..bb9f6e1a37 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -1,6 +1,7 @@ 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 @@ -215,6 +216,8 @@ class BlocktankRepoTest : BaseUnitTest() { // but it never opened a channel. It must not be mistaken for the freshly opened channel. val staleEntry = mock() 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() @@ -226,6 +229,7 @@ class BlocktankRepoTest : BaseUnitTest() { @Test fun `getCjitEntry matches the entry whose channel funding tx matches`() = test { sut = createSut() + seedCjitEntries(pendingCjitEntry()) val fundingTxId = "cjit-funding-tx" val matchingChannel = mock() whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) @@ -246,6 +250,7 @@ class BlocktankRepoTest : BaseUnitTest() { @Test fun `getCjitEntry returns null when no CJIT channel funding tx matches`() = test { sut = createSut() + seedCjitEntries(pendingCjitEntry()) val channel = mock() whenever(channel.fundingTx).thenReturn(FundingTx(id = "cjit-funding-tx", vout = 0u)) val entry = mock() @@ -258,6 +263,53 @@ class BlocktankRepoTest : BaseUnitTest() { 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() + whenever(associatedChannel.fundingTx).thenReturn(FundingTx(id = "other-funding-tx", vout = 0u)) + val associatedEntry = mock() + whenever(associatedEntry.channel).thenReturn(associatedChannel) + seedCjitEntries(associatedEntry) + + val fundingTxId = "channel-order-funding-tx" + val matchingChannel = mock() + whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val matchingEntry = mock() + whenever(matchingEntry.channel).thenReturn(matchingChannel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(matchingEntry)) + + val channelDetails = mock() + 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() + whenever(expiredEntry.channel).thenReturn(null) + whenever(expiredEntry.state).thenReturn(CJitStateEnum.EXPIRED) + seedCjitEntries(expiredEntry) + + val fundingTxId = "channel-order-funding-tx" + val matchingChannel = mock() + whenever(matchingChannel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val matchingEntry = mock() + whenever(matchingEntry.channel).thenReturn(matchingChannel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(matchingEntry)) + + val channelDetails = mock() + 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() @@ -266,10 +318,7 @@ class BlocktankRepoTest : BaseUnitTest() { whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) val cachedEntry = mock() whenever(cachedEntry.channel).thenReturn(channel) - - sut.restoreFromBackup( - BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = listOf(cachedEntry)), - ) + seedCjitEntries(cachedEntry) val channelDetails = mock() whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u)) @@ -280,8 +329,9 @@ class BlocktankRepoTest : BaseUnitTest() { } @Test - fun `getCjitEntry returns null when cache misses and refresh fails`() = 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() @@ -293,6 +343,7 @@ class BlocktankRepoTest : BaseUnitTest() { @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() whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) @@ -307,4 +358,15 @@ class BlocktankRepoTest : BaseUnitTest() { assertEquals(entry, sut.getCjitEntry(channelDetails)) } + + private fun pendingCjitEntry(): IcJitEntry = mock().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()), + ) + } } From 25320431e7996d1fcca1528434eb5eec3ca8befd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 12:02:41 -0300 Subject: [PATCH 06/12] fix: match cjit against fetched list to avoid state race --- .../to/bitkit/repositories/BlocktankRepo.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 6086e46646..585c64968a 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -131,23 +131,24 @@ class BlocktankRepo @Inject constructor( suspend fun getCjitEntry(channel: ChannelDetails): IcJitEntry? = withContext(bgDispatcher) { val fundingTxId = channel.fundingTxo?.txid ?: return@withContext null - fun cachedMatch(): IcJitEntry? = _blocktankState.value.cjitEntries - .firstOrNull { it.channel?.fundingTx?.id == fundingTxId } + fun List.matching(): IcJitEntry? = + firstOrNull { it.channel?.fundingTx?.id == fundingTxId } - cachedMatch()?.let { return@withContext it } + val cached = _blocktankState.value.cjitEntries + cached.matching()?.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 { + val hasPendingCjit = cached.any { it.channel == null && it.state != CJitStateEnum.EXPIRED && it.state != CJitStateEnum.FAILED } if (!hasPendingCjit) return@withContext null - refreshCjitEntries() - return@withContext cachedMatch() + // Match against the freshly fetched list so a concurrent refreshOrders() can't clobber state before we read. + return@withContext refreshCjitEntries().matching() } - private suspend fun refreshCjitEntries() { + private suspend fun refreshCjitEntries(): List { repeat(CJIT_REFRESH_ATTEMPTS) { attempt -> runCatching { withTimeout(CJIT_REFRESH_TIMEOUT) { @@ -155,16 +156,16 @@ class BlocktankRepo @Inject constructor( } }.onSuccess { entries -> _blocktankState.update { it.copy(cjitEntries = entries.toImmutableList()) } - return + return entries }.onFailure { if (it is CancellationException && it !is TimeoutCancellationException) throw it 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) } + return _blocktankState.value.cjitEntries } suspend fun refreshInfo() = withContext(bgDispatcher) { From d1c40928a44fd82d02bd0496e94a92e6ca41df9b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 12:13:00 -0300 Subject: [PATCH 07/12] fix: add a guard for edge case were the LSP batches two channel opens to the same wallet into one funding transaction while two CJIT invoices are pending, so their entries share a txid and differ only by vout --- .../to/bitkit/repositories/BlocktankRepo.kt | 8 +++++--- .../bitkit/repositories/BlocktankRepoTest.kt | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 585c64968a..656bdd903f 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -129,10 +129,12 @@ class BlocktankRepo @Inject constructor( } suspend fun getCjitEntry(channel: ChannelDetails): IcJitEntry? = withContext(bgDispatcher) { - val fundingTxId = channel.fundingTxo?.txid ?: return@withContext null + val fundingTxo = channel.fundingTxo ?: return@withContext null - fun List.matching(): IcJitEntry? = - firstOrNull { it.channel?.fundingTx?.id == fundingTxId } + fun List.matching(): IcJitEntry? = firstOrNull { entry -> + val fundingTx = entry.channel?.fundingTx ?: return@firstOrNull false + fundingTx.id == fundingTxo.txid && fundingTx.vout == fundingTxo.vout.toULong() + } val cached = _blocktankState.value.cjitEntries cached.matching()?.let { return@withContext it } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index bb9f6e1a37..ebdaaac75e 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -263,6 +263,24 @@ class BlocktankRepoTest : BaseUnitTest() { assertNull(sut.getCjitEntry(channelDetails)) } + @Test + fun `getCjitEntry does not match a CJIT entry with the same funding txid but a different vout`() = test { + sut = createSut() + seedCjitEntries(pendingCjitEntry()) + // A batched funding tx can hold several channel outputs sharing one txid; only vout distinguishes them. + val sharedTxId = "shared-funding-tx" + val channel = mock() + whenever(channel.fundingTx).thenReturn(FundingTx(id = sharedTxId, vout = 0u)) + val entry = mock() + whenever(entry.channel).thenReturn(channel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(entry)) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = sharedTxId, vout = 1u)) + + assertNull(sut.getCjitEntry(channelDetails)) + } + @Test fun `getCjitEntry does not refresh when no cached CJIT entry is awaiting a channel`() = test { sut = createSut() From 2c7c0cb313e082494ff6cee4129f5f426c0bd968 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 09:54:11 -0300 Subject: [PATCH 08/12] chore: create helper for re-throw CancellationException --- app/src/main/java/to/bitkit/ext/Coroutines.kt | 11 ++++++ .../test/java/to/bitkit/ext/CoroutinesTest.kt | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/src/test/java/to/bitkit/ext/CoroutinesTest.kt diff --git a/app/src/main/java/to/bitkit/ext/Coroutines.kt b/app/src/main/java/to/bitkit/ext/Coroutines.kt index 692cd985a5..a996ba57bd 100644 --- a/app/src/main/java/to/bitkit/ext/Coroutines.kt +++ b/app/src/main/java/to/bitkit/ext/Coroutines.kt @@ -1,8 +1,19 @@ package to.bitkit.ext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import to.bitkit.utils.Logger +@Suppress("TooGenericExceptionCaught") +suspend inline fun runSuspendCatching(block: () -> R): Result = + try { + Result.success(block()) + } catch (c: CancellationException) { + throw c + } catch (e: Throwable) { + Result.failure(e) + } + fun Job.logCompletion(name: String = "") = invokeOnCompletion { err -> if (err != null) { Logger.verbose("Coroutine '$name' error: ${err.message}") diff --git a/app/src/test/java/to/bitkit/ext/CoroutinesTest.kt b/app/src/test/java/to/bitkit/ext/CoroutinesTest.kt new file mode 100644 index 0000000000..123d8df3ff --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/CoroutinesTest.kt @@ -0,0 +1,35 @@ +package to.bitkit.ext + +import kotlinx.coroutines.CancellationException +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class CoroutinesTest : BaseUnitTest() { + + @Test + fun `runSuspendCatching wraps a successful result`() = test { + val result = runSuspendCatching { 42 } + + assertEquals(42, result.getOrNull()) + } + + @Test + fun `runSuspendCatching wraps a thrown exception as failure`() = test { + val error = IllegalStateException("boom") + + val result = runSuspendCatching { throw error } + + assertTrue(result.isFailure) + assertEquals(error, result.exceptionOrNull()) + } + + @Test + fun `runSuspendCatching re-throws CancellationException`() = test { + assertFailsWith { + runSuspendCatching { throw CancellationException("cancelled") } + } + } +} From ee95a66333b87c87b202b92f4d9a92e244aa08fc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 09:55:59 -0300 Subject: [PATCH 09/12] chore: update canCatching inside suspending function rule to use runSuspendCatching --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0dbfbefc5e..ec1a631f61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ fun updateState(action: Action) { ```kotlin suspend fun getData(): Result = withContext(Dispatchers.IO) { - runCatching { + runSuspendCatching { apiService.fetchData() }.onFailure { Logger.error("Failed", it, context = TAG) @@ -196,6 +196,8 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS log errors at the final handling layer where the error is acted upon, not in intermediate layers that just propagate it - ALWAYS use the Result API instead of try-catch - NEVER wrap methods returning `Result` in try-catch +- ALWAYS use `runSuspendCatching` (from `ext/Coroutines.kt`) instead of `runCatching` when the block calls suspend functions or runs in a coroutine — it re-throws `CancellationException` so structured-concurrency cancellation is preserved; plain `runCatching` catches `Throwable` and swallows it. NEVER log a `CancellationException` as an error +- EXCEPTION: when a `TimeoutCancellationException` from `withTimeout` must be treated as a retriable failure, use `runCatching` with an explicit `if (it is CancellationException && it !is TimeoutCancellationException) throw it` guard (e.g. `BlocktankRepo.refreshCjitEntries`) - PREFER to use `it` instead of explicit named parameters in lambdas e.g. `fn().onSuccess { log(it) }.onFailure { log(it) }` - NEVER inject ViewModels as dependencies - Only android activities and composable functions can use viewmodels - ALWAYS co-locate screen-specific ViewModels in the same package as their screen; only place ViewModels in `viewmodels/` when shared across multiple screens From 2e7a7e6fc00d20134be2de821e12fd4a967046b3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 09:57:04 -0300 Subject: [PATCH 10/12] refactor: replace onFailure with mapCatching to fix code smell of re-throw inside a side function --- .../main/java/to/bitkit/repositories/BlocktankRepo.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 656bdd903f..2d7e3948d8 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -152,19 +152,22 @@ class BlocktankRepo @Inject constructor( private suspend fun refreshCjitEntries(): List { repeat(CJIT_REFRESH_ATTEMPTS) { attempt -> - runCatching { + val entries = runCatching { withTimeout(CJIT_REFRESH_TIMEOUT) { coreService.blocktank.cjitEntries(refresh = true) } - }.onSuccess { entries -> + }.mapCatching { entries -> _blocktankState.update { it.copy(cjitEntries = entries.toImmutableList()) } - return entries - }.onFailure { + entries + }.getOrElse { if (it is CancellationException && it !is TimeoutCancellationException) throw it if (attempt == CJIT_REFRESH_ATTEMPTS - 1) { Logger.warn("Failed to refresh CJIT entries; using cached state", it, context = TAG) } + null } + + entries?.let { return it } delay(CJIT_REFRESH_RETRY_DELAY) } return _blocktankState.value.cjitEntries From c416075e6632f869d1523a156cdd8d023f1db86a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 09:58:21 -0300 Subject: [PATCH 11/12] fix: replace runCatching with runSuspendCatching to propagate CancellationException gracefully --- .../bitkit/domain/commands/NotifyChannelReadyHandler.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt index 34cd2e2469..ecbd692ff0 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.amountOnClose +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -30,17 +31,17 @@ class NotifyChannelReadyHandler @Inject constructor( suspend operator fun invoke( command: NotifyChannelReady.Command, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val channel = lightningRepo.getChannels() ?.find { it.channelId == command.event.channelId } - ?: return@runCatching NotifyChannelReady.Result.Skip + ?: return@runSuspendCatching NotifyChannelReady.Result.Skip val cjitEntry = blocktankRepo.getCjitEntry(channel) - ?: return@runCatching NotifyChannelReady.Result.Skip + ?: return@runSuspendCatching NotifyChannelReady.Result.Skip val inserted = activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) .getOrDefault(false) - if (!inserted) return@runCatching NotifyChannelReady.Result.Duplicate + if (!inserted) return@runSuspendCatching NotifyChannelReady.Result.Duplicate val sats = channel.amountOnClose.toLong() From c5ad4fafe7db144aee1e46554cc74fb54a08383f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 19 Jun 2026 12:27:53 -0300 Subject: [PATCH 12/12] fix: refresh cjit on empty cache --- .../to/bitkit/repositories/BlocktankRepo.kt | 2 +- .../bitkit/repositories/BlocktankRepoTest.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 2d7e3948d8..4ca4164b3b 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -144,7 +144,7 @@ class BlocktankRepo @Inject constructor( val hasPendingCjit = cached.any { it.channel == null && it.state != CJitStateEnum.EXPIRED && it.state != CJitStateEnum.FAILED } - if (!hasPendingCjit) return@withContext null + if (cached.isNotEmpty() && !hasPendingCjit) return@withContext null // Match against the freshly fetched list so a concurrent refreshOrders() can't clobber state before we read. return@withContext refreshCjitEntries().matching() diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index ebdaaac75e..6a7cf2bd69 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -377,6 +377,24 @@ class BlocktankRepoTest : BaseUnitTest() { assertEquals(entry, sut.getCjitEntry(channelDetails)) } + @Test + fun `getCjitEntry refreshes when the cache is empty`() = test { + sut = createSut() + // Cold start / right after createCjit: cjitEntries hasn't loaded yet, so an empty cache must not be + // treated as a terminal no-match — the server refresh has to surface the matching entry. + val fundingTxId = "cjit-funding-tx" + val channel = mock() + whenever(channel.fundingTx).thenReturn(FundingTx(id = fundingTxId, vout = 0u)) + val entry = mock() + whenever(entry.channel).thenReturn(channel) + whenever(coreService.blocktank.cjitEntries(refresh = true)).thenReturn(listOf(entry)) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(OutPoint(txid = fundingTxId, vout = 0u)) + + assertEquals(entry, sut.getCjitEntry(channelDetails)) + } + private fun pendingCjitEntry(): IcJitEntry = mock().apply { whenever(channel).thenReturn(null) whenever(state).thenReturn(CJitStateEnum.CREATED)