diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..445365a6d4 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 { @@ -966,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/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/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/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..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 @@ -34,6 +37,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 +122,48 @@ 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 `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( diff --git a/changelog.d/next/1682.fixed.md b/changelog.d/next/1682.fixed.md new file mode 100644 index 0000000000..7050469aaf --- /dev/null +++ b/changelog.d/next/1682.fixed.md @@ -0,0 +1 @@ +Show locally available transaction details for restored payments while keeping background synchronization limited to cached details.