From 3886fad4a14d24d65e6023583228281a1e86af33 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 24 Oct 2026 10:32:52 +0200 Subject: [PATCH 1/5] fix: read missing transaction details from the local wallet --- .../java/to/bitkit/services/CoreService.kt | 18 ++++++++-- .../to/bitkit/services/LightningService.kt | 11 ++++++ .../to/bitkit/services/ActivityServiceTest.kt | 34 +++++++++++++++++-- .../bitkit/services/LightningServiceTest.kt | 16 +++++++++ .../next/local-transaction-details.fixed.md | 1 + 5 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 changelog.d/next/local-transaction-details.fixed.md diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..2e80be6a86 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -126,7 +126,8 @@ class CoreService @Inject constructor( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { - private var walletIndex: Int = 0 + internal var walletIndex: Int = 0 + private set val activity: ActivityService by lazy { ActivityService( @@ -550,8 +551,19 @@ class ActivityService( suspend fun getTransactionDetails( txid: String, walletId: String = defaultWalletId, - ): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) + ): BitkitCoreTransactionDetails? { + val cached = ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) + } + if (cached != null) return cached + if (walletId != defaultWalletId || txid.length != 64 || + txid.any { it !in '0'..'9' && it !in 'a'..'f' && it !in 'A'..'F' } + ) { + return null + } + return lightningService.getTransactionDetails(txid, coreService.walletIndex)?.let { + mapToCoreTransactionDetails(txid, it) + } } suspend fun getActivity(id: String, walletId: String = defaultWalletId): Activity? = ServiceQueue.CORE.background { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 78a272c4c9..576f39e576 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1064,6 +1064,17 @@ class LightningService internal constructor( return original.any { it !in spendable } } + suspend fun getTransactionDetails(txid: String, walletIndex: Int): TransactionDetails? { + val originalNode = node ?: return null + if (currentWalletIndex != walletIndex) return null + val details = callOnchainSend { + if (currentWalletIndex != walletIndex || node !== originalNode) return@callOnchainSend null + originalNode.getTransactionDetails(txid) + } + if (currentWalletIndex != walletIndex || node !== originalNode) return null + return details + } + suspend fun walletKnowsTransaction(txid: String): Boolean { val originalNode = node ?: return false return callOnchainSend { originalNode.getTransactionDetails(txid) != null } diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt index b0eb0cc4cc..d3ba32dbad 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt @@ -21,6 +21,7 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.mockStatic import org.mockito.Mockito.never import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue import to.bitkit.data.AppCacheData @@ -30,7 +31,10 @@ import to.bitkit.ext.scopedActivityId import to.bitkit.models.WalletScope import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue +import com.synonym.bitkitcore.TransactionDetails as CoreTransactionDetails +import org.lightningdevkit.ldknode.TransactionDetails as LdkTransactionDetails class ActivityServiceTest : BaseUnitTest() { companion object { @@ -45,17 +49,43 @@ class ActivityServiceTest : BaseUnitTest() { private val markActivityAsSeen = binding.methods.single { it.name.startsWith("markActivityAsSeen") } private val cacheStore = mock() + private val lightningService = mock() + private val coreService = mock() private val sut by lazy { ActivityService( - coreService = mock(), + coreService = coreService, cacheStore = cacheStore, - lightningService = mock(), + lightningService = lightningService, settingsStore = mock(), privatePaykitContactResolver = mock(), ) } + @Test + fun `transaction detail cache misses use only the default wallets local node`() = test { + val txid = "ab".repeat(32) + val walletId = WalletScope.default + val stored = CoreTransactionDetails(walletId, txid, 5000L, emptyList(), emptyList()) + val details = LdkTransactionDetails(5000L, emptyList(), emptyList()) + val getDetails = binding.getMethod("getTransactionDetails", String::class.java, String::class.java) + whenever(coreService.walletIndex).thenReturn(7) + whenever(lightningService.getTransactionDetails(txid, 7)).thenReturn(details) + ServiceQueue.CORE.background { + mockStatic(binding).use { native -> + native.`when` { getDetails.invoke(null, walletId, txid) } + .thenReturn(stored, null) + assertEquals(stored, sut.getTransactionDetails(txid)) + verify(lightningService, never()).getTransactionDetails(txid, 7) + assertEquals(stored, sut.getTransactionDetails(txid)) + verify(lightningService).getTransactionDetails(txid, 7) + assertNull(sut.getTransactionDetails(txid, "hardware-wallet")) + assertNull(sut.getTransactionDetails("invalid")) + verify(lightningService, never()).getTransactionDetails("invalid", 7) + } + } + } + @Test fun `markActivityAsSeen persists the timestamp through the core seen call`() = test { ServiceQueue.CORE.background { diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 2e9736f816..f7809cbcb0 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -34,6 +34,7 @@ import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import org.lightningdevkit.ldknode.SpendableUtxo +import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer @@ -118,6 +119,21 @@ class LightningServiceTest : BaseUnitTest() { sut.node = node } + @Test + fun `local transaction details cannot cross a wallet or node replacement`() = test { + val txid = "ab".repeat(32) + val details = TransactionDetails(5000L, emptyList(), emptyList()) + whenever(node.getTransactionDetails(txid)).thenReturn(details) + assertNull(sut.getTransactionDetails(txid, 7)) + verify(node, never()).getTransactionDetails(txid) + assertEquals(details, sut.getTransactionDetails(txid, 0)) + whenever(node.getTransactionDetails(txid)).thenAnswer { + sut.node = mock() + details + } + assertNull(sut.getTransactionDetails(txid, 0)) + } + @Test fun `canReceive returns false when channel is ready but not usable`() { val readyButNotUsable = createChannelDetails().copy( diff --git a/changelog.d/next/local-transaction-details.fixed.md b/changelog.d/next/local-transaction-details.fixed.md new file mode 100644 index 0000000000..f98f0b53ea --- /dev/null +++ b/changelog.d/next/local-transaction-details.fixed.md @@ -0,0 +1 @@ +Show locally available transaction details when restored activity is missing its cached details. From 92f8b86d9ea667bc885b2fbb8c29e43ded74989b Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 24 Oct 2026 10:34:54 +0200 Subject: [PATCH 2/5] chore: rename changelog fragment --- .../next/{local-transaction-details.fixed.md => 1682.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{local-transaction-details.fixed.md => 1682.fixed.md} (100%) diff --git a/changelog.d/next/local-transaction-details.fixed.md b/changelog.d/next/1682.fixed.md similarity index 100% rename from changelog.d/next/local-transaction-details.fixed.md rename to changelog.d/next/1682.fixed.md From 588da10f0a4bc5109393d274a3ef1a590ad8aa79 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 24 Oct 2026 10:54:30 +0200 Subject: [PATCH 3/5] test: cover queued transaction lookup after node replacement --- .../bitkit/services/LightningServiceTest.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index f7809cbcb0..1e183ef287 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -2,13 +2,16 @@ package to.bitkit.services import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json @@ -134,6 +137,33 @@ class LightningServiceTest : BaseUnitTest() { assertNull(sut.getTransactionDetails(txid, 0)) } + @Test + fun `queued transaction lookup does not query a replaced node`() = test { + val queued = LightningService( + bgDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + keychain = keychain, + vssStoreIdProvider = vssStoreIdProvider, + settingsStore = settingsStore, + watchOnlyAccountStore = watchOnlyAccountStore, + loggerLdk = loggerLdk, + watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, + ldkQueue = StandardTestDispatcher(testScheduler), + ) + val txid = "ab".repeat(32) + val replacement = mock() + queued.node = node + val lookup = async(start = CoroutineStart.UNDISPATCHED) { + queued.getTransactionDetails(txid, 0) + } + assertFalse(lookup.isCompleted) + verify(node, never()).getTransactionDetails(txid) + queued.node = replacement + assertNull(lookup.await()) + verify(node, never()).getTransactionDetails(txid) + verify(replacement, never()).getTransactionDetails(txid) + } + @Test fun `canReceive returns false when channel is ready but not usable`() { val readyButNotUsable = createChannelDetails().copy( From 96b5415393a9446999447ff23e54c7006c5bc26c Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 21:00:40 +0200 Subject: [PATCH 4/5] fix: keep transaction synchronization cache only --- .../java/to/bitkit/services/CoreService.kt | 4 +++- .../ActivityServicePaykitContactsTest.kt | 18 ++++++++++++++++++ changelog.d/next/1682.fixed.md | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 2e80be6a86..445365a6d4 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -978,7 +978,9 @@ class ActivityService( * Returns the first address found in pre-activity metadata that matches a transaction output */ private suspend fun fetchTransactionDetails(txid: String): BitkitCoreTransactionDetails? = runCatching { - getTransactionDetails(txid) + ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(walletId = defaultWalletId, txId = txid) + } }.onFailure { Logger.warn("Failed to fetch stored transaction details for '$txid'", it, context = TAG) }.getOrNull() diff --git a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt index 2874cd3c9d..189288e4be 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServicePaykitContactsTest.kt @@ -499,6 +499,24 @@ class ActivityServicePaykitContactsTest : BaseUnitTest() { verify(contacts, never()).contactsForAddresses(any()) } + @Test + fun `repeated incoming payment sync does not fetch missing node transaction details`() = coreTest { + val txid = "ab".repeat(32) + val incoming = payment().copy(kind = PaymentKind.Onchain(txid, ConfirmationStatus.Unconfirmed)) + whenever(lightningService.getTransactionDetails(txid, 0)).thenReturn( + LdkTransactionDetails(15_000, emptyList(), listOf(LdkTxOutput("", "", "request-address", 15_000, 0u))), + ) + + repeat(2) { + sut.syncLdkNodePaymentsToActivities(listOf(incoming)) + rows = listOf(updates.last()) + } + + assertEquals(txid, (rows.single() as Activity.Onchain).v1.txId) + verify(lightningService, never()).getTransactionDetails(any(), any()) + verify(lightningService, never()).addressInfosForType(any(), any(), any(), any(), any()) + } + private suspend fun receive(vararg addresses: String) { whenever(lightningService.listPayments()).thenReturn(listOf(payment())) sut.handleOnchainTransactionReceived( diff --git a/changelog.d/next/1682.fixed.md b/changelog.d/next/1682.fixed.md index f98f0b53ea..28ed9a5940 100644 --- a/changelog.d/next/1682.fixed.md +++ b/changelog.d/next/1682.fixed.md @@ -1 +1 @@ -Show locally available transaction details when restored activity is missing its cached details. +Show locally available transaction details when restored activity is missing its cached details. Background payment and closed-channel sync use cached transaction details without repeating the details-screen node fallback. From 89ee1fe891a034e4941522a9b7b898ea725d30e9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 21:08:24 +0200 Subject: [PATCH 5/5] docs: keep transaction recovery changelog concise --- changelog.d/next/1682.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1682.fixed.md b/changelog.d/next/1682.fixed.md index 28ed9a5940..7050469aaf 100644 --- a/changelog.d/next/1682.fixed.md +++ b/changelog.d/next/1682.fixed.md @@ -1 +1 @@ -Show locally available transaction details when restored activity is missing its cached details. Background payment and closed-channel sync use cached transaction details without repeating the details-screen node fallback. +Show locally available transaction details for restored payments while keeping background synchronization limited to cached details.