Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 18 additions & 4 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ class CoreService @Inject constructor(
private val settingsStore: SettingsStore,
private val privatePaykitContactResolver: Provider<PrivatePaykitContactResolver>,
) {
private var walletIndex: Int = 0
internal var walletIndex: Int = 0
private set

val activity: ActivityService by lazy {
ActivityService(
Expand Down Expand Up @@ -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 {
Comment thread
ben-kaufman marked this conversation as resolved.
mapToCoreTransactionDetails(txid, it)
}
}

suspend fun getActivity(id: String, walletId: String = defaultWalletId): Activity? = ServiceQueue.CORE.background {
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 32 additions & 2 deletions app/src/test/java/to/bitkit/services/ActivityServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -45,17 +49,43 @@ class ActivityServiceTest : BaseUnitTest() {
private val markActivityAsSeen = binding.methods.single { it.name.startsWith("markActivityAsSeen") }

private val cacheStore = mock<CacheStore>()
private val lightningService = mock<LightningService>()
private val coreService = mock<CoreService>()

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`<CoreTransactionDetails?> { 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 {
Expand Down
46 changes: 46 additions & 0 deletions app/src/test/java/to/bitkit/services/LightningServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Comment thread
ben-kaufman marked this conversation as resolved.
}

@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<Node>()
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(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1682.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show locally available transaction details for restored payments while keeping background synchronization limited to cached details.
Loading