diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index f6527a5212..884c027bb3 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,13 +5,24 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import to.bitkit.models.WalletScope +import to.bitkit.models.ActivityWalletType + +val DEFAULT_WALLET_ID: String get() = ActivityWalletType.BITKIT.id() fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id is Activity.Onchain -> v1.id } +fun Activity.walletId(): String = when (this) { + is Activity.Lightning -> v1.walletId + is Activity.Onchain -> v1.walletId +} + +fun Activity.scopedId(): String = "${walletId()}:${rawId()}" + +fun Activity.isFromHardwareWallet(): Boolean = ActivityWalletType.TREZOR.owns(walletId()) + fun Activity.txType(): PaymentType = when (this) { is Activity.Lightning -> v1.txType is Activity.Onchain -> v1.txType @@ -95,7 +106,6 @@ enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") fun LightningActivity.Companion.create( - walletId: String = WalletScope.default, id: String, txType: PaymentType, status: PaymentState, @@ -109,6 +119,7 @@ fun LightningActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = LightningActivity( walletId = walletId, id = id, @@ -128,7 +139,6 @@ fun LightningActivity.Companion.create( @Suppress("LongParameterList") fun OnchainActivity.Companion.create( - walletId: String = WalletScope.default, id: String, txType: PaymentType, txId: String, @@ -149,6 +159,7 @@ fun OnchainActivity.Companion.create( createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, + walletId: String = DEFAULT_WALLET_ID, ) = OnchainActivity( walletId = walletId, id = id, diff --git a/app/src/main/java/to/bitkit/models/ActivityWalletType.kt b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt new file mode 100644 index 0000000000..f3b110f3bd --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActivityWalletType.kt @@ -0,0 +1,15 @@ +package to.bitkit.models + +import java.util.Locale + +enum class ActivityWalletType { + BITKIT, TREZOR; + + fun id(): String = name.lowercase(Locale.US) + + fun idPrefixed(value: String): String = "${prefix()}$value" + + fun owns(walletId: String): Boolean = walletId == id() || walletId.startsWith(prefix()) + + private fun prefix(): String = "${id()}:" +} diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a01..2c7f3eb48b 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -37,6 +37,7 @@ data class HwWalletBalance( data class HwWalletReceivedTx( val txid: String, val sats: ULong, + val walletId: String, ) sealed interface HwFundingAccount { diff --git a/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt new file mode 100644 index 0000000000..3637575d7e --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWalletIdentity.kt @@ -0,0 +1,33 @@ +package to.bitkit.models + +val KnownDevice.identityKey: String + get() = identityKey(xpubs, id) + +fun identityKey(xpubs: Map, fallback: String): String = + xpubs.values.sorted().joinToString().ifEmpty { fallback } + +fun List.findWalletId( + deviceId: String, + xpubs: Map, + deriveWalletId: (Collection) -> String, +): String { + val targetIdentityKey = identityKey(xpubs, deviceId) + firstOrNull { it.identityKey == targetIdentityKey }?.walletId?.takeIf { it.isNotBlank() }?.let { return it } + if (xpubs.values.any { it.isNotBlank() }) return deriveWalletId(xpubs.values) + + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() }.orEmpty() +} + +fun List.withWalletIds( + deriveWalletId: (Collection) -> String, +): List { + val existingByIdentity = filter { it.walletId.isNotBlank() } + .associate { it.identityKey to it.walletId } + val generatedByIdentity = mutableMapOf() + + return map { + val walletId = existingByIdentity[it.identityKey] + ?: generatedByIdentity.getOrPut(it.identityKey) { deriveWalletId(it.xpubs.values) } + if (it.walletId == walletId) it else it.copy(walletId = walletId) + } +} diff --git a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt index 11b4610d71..004c985fd1 100644 --- a/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt +++ b/app/src/main/java/to/bitkit/models/NewTransactionSheetDetails.kt @@ -10,6 +10,7 @@ data class NewTransactionSheetDetails( val direction: NewTransactionSheetDirection, val paymentHashOrTxId: String? = null, val activityId: String? = null, + val activityWalletId: String? = null, val sats: Long = 0, val isLoadingDetails: Boolean = false, ) { diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 9de8bc1ba4..fb07bc012f 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -34,14 +34,15 @@ import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountOnClose import to.bitkit.ext.contact -import to.bitkit.ext.create import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService @@ -178,8 +179,8 @@ class ActivityRepo @Inject constructor( private suspend fun findClosedChannelForTransaction(txid: String): String? = coreService.activity.findClosedChannelForTransaction(txid, null) - suspend fun getOnchainActivityByTxId(txid: String): OnchainActivity? = - coreService.activity.getOnchainActivityByTxId(txid) + suspend fun getOnchainActivityByTxId(txid: String, walletId: String? = null): OnchainActivity? = + coreService.activity.getOnchainActivityByTxId(txid, walletId) /** * Checks if a transaction is inbound (received) by looking up the payment direction. @@ -214,23 +215,31 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } - suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { - runCatching { - val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching - val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp - .takeIf { activity.confirmed } - val updated = existing.copy( - confirmed = existing.confirmed || activity.confirmed, - confirmTimestamp = confirmTimestamp, - doesExist = if (activity.confirmed) true else existing.doesExist, - fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee, - updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp), + suspend fun persistHardware( + walletId: String, + activities: List, + transactionDetails: List, + ): Result> = withContext(bgDispatcher) { + runSuspendCatching { + val persistedActivities = coreService.activity.replaceHardwareSnapshot( + walletId = walletId, + activities = activities, + transactionDetails = transactionDetails, ) - if (updated == existing) return@runCatching - coreService.activity.update(existing.id, Activity.Onchain(updated)) notifyActivitiesChanged() + persistedActivities + }.onFailure { + Logger.error("Failed to persist hardware activities", it, context = TAG) + } + } + + suspend fun deleteForWallet(walletId: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + val deleted = coreService.activity.deleteByWalletId(walletId) + notifyActivitiesChanged() + Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG) }.onFailure { - Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG) } } @@ -258,31 +267,36 @@ class ActivityRepo @Inject constructor( return coreService.activity.shouldShowReceivedSheet(txid, value) } - suspend fun isActivitySeen(activityId: String): Boolean { - return coreService.activity.isActivitySeen(activityId) + suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean { + return coreService.activity.isActivitySeen(activityId, walletId) } - suspend fun markActivityAsSeen(activityId: String) { - coreService.activity.markActivityAsSeen(activityId) + suspend fun markActivityAsSeen(activityId: String, walletId: String? = null) { + coreService.activity.markActivityAsSeen(activityId, walletId = walletId) notifyActivitiesChanged() } - suspend fun markOnchainActivityAsSeen(txid: String) { - coreService.activity.markOnchainActivityAsSeen(txid) + suspend fun markOnchainActivityAsSeen(txid: String, walletId: String? = null) { + coreService.activity.markOnchainActivityAsSeen(txid, walletId = walletId) notifyActivitiesChanged() } - suspend fun getTransactionDetails(txid: String): Result = runCatching { - coreService.activity.getTransactionDetails(txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String? = null, + ): Result = runCatching { + coreService.activity.getTransactionDetails(txid, walletId) } - suspend fun getBoostTxDoesExist(boostTxIds: List): Map { - return coreService.activity.getBoostTxDoesExist(boostTxIds) + suspend fun getBoostTxDoesExist(boostTxIds: List, walletId: String): Map { + return coreService.activity.getBoostTxDoesExist(boostTxIds, walletId) } - suspend fun isCpfpChildTransaction(txId: String): Boolean = coreService.activity.isCpfpChildTransaction(txId) + suspend fun isCpfpChildTransaction(txId: String, walletId: String): Boolean = + coreService.activity.isCpfpChildTransaction(txId, walletId) - suspend fun getTxIdsInBoostTxIds(): Set = coreService.activity.getTxIdsInBoostTxIds() + suspend fun getTxIdsInBoostTxIds(walletId: String): Set = + coreService.activity.getTxIdsInBoostTxIds(walletId) /** * Gets a specific activity by payment hash or txID with retry logic @@ -328,6 +342,7 @@ class ActivityRepo @Inject constructor( } suspend fun getActivities( + walletId: String? = DEFAULT_WALLET_ID, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -337,8 +352,8 @@ class ActivityRepo @Inject constructor( limit: UInt? = null, sortDirection: SortDirection? = null, ): Result> = withContext(bgDispatcher) { - runCatching { - coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + runSuspendCatching { + coreService.activity.get(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection) }.onFailure { Logger.error( "getActivities error. Parameters:" + @@ -356,9 +371,9 @@ class ActivityRepo @Inject constructor( } } - suspend fun getActivity(id: String): Result = withContext(bgDispatcher) { + suspend fun getActivity(id: String, walletId: String? = null): Result = withContext(bgDispatcher) { runCatching { - coreService.activity.getActivity(id) + coreService.activity.getActivity(id, walletId) }.onFailure { Logger.error("getActivity error for ID: $id", it, context = TAG) } @@ -367,7 +382,7 @@ class ActivityRepo @Inject constructor( suspend fun contactActivities(publicKey: String): Result> = withContext(ioDispatcher) { runCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey - val txIdsInBoostTxIds = getTxIdsInBoostTxIds() + val txIdsInBoostTxIds = getTxIdsInBoostTxIds(DEFAULT_WALLET_ID) getActivities( filter = ActivityFilter.ALL, sortDirection = SortDirection.DESC, @@ -530,12 +545,6 @@ class ActivityRepo @Inject constructor( Logger.debug("Activity $id updated with success. new data: $activity", context = TAG) val tags = coreService.activity.tags(activityIdToDelete) addTagsToActivity(activityId = id, tags = tags) - }.onFailure { - Logger.error( - "updateActivity error: id:$id, activityIdToDelete:$activityIdToDelete activity:$activity", - it, - context = TAG, - ) } } @@ -654,7 +663,8 @@ class ActivityRepo @Inject constructor( val now = nowTimestamp().epochSecond.toULong() insertActivity( Activity.Lightning( - LightningActivity.create( + LightningActivity( + walletId = DEFAULT_WALLET_ID, id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, @@ -685,15 +695,14 @@ class ActivityRepo @Inject constructor( suspend fun addTagsToActivity( activityId: String, tags: List, + walletId: String? = null, ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } - - val existingTags = coreService.activity.tags(activityId) + val existingTags = coreService.activity.tags(activityId, walletId) val newTags = tags.filter { it.isNotBlank() && it !in existingTags } if (newTags.isNotEmpty()) { - coreService.activity.appendTags(activityId, newTags).getOrThrow() + coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow() notifyActivitiesChanged() Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG) } else { @@ -727,12 +736,14 @@ class ActivityRepo @Inject constructor( /** * Removes tags from an activity */ - suspend fun removeTagsFromActivity(activityId: String, tags: List): Result = + suspend fun removeTagsFromActivity( + activityId: String, + tags: List, + walletId: String? = null, + ): Result = withContext(bgDispatcher) { runCatching { - checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } - - coreService.activity.dropTags(activityId, tags) + coreService.activity.dropTags(activityId, tags, walletId) notifyActivitiesChanged() Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG) }.onFailure { @@ -743,9 +754,12 @@ class ActivityRepo @Inject constructor( /** * Gets all tags for an activity */ - suspend fun getActivityTags(activityId: String): Result> = withContext(bgDispatcher) { + suspend fun getActivityTags( + activityId: String, + walletId: String? = null, + ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.tags(activityId) + coreService.activity.tags(activityId, walletId) }.onFailure { Logger.error("getActivityTags error for activity $activityId", it, context = TAG) } @@ -764,9 +778,12 @@ class ActivityRepo @Inject constructor( /** * Get all [ActivityTags] for backup */ - suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { + suspend fun getAllActivitiesTags( + walletId: String? = DEFAULT_WALLET_ID, + ): Result> = withContext(bgDispatcher) { runCatching { - coreService.activity.getAllActivitiesTags() + val tags = coreService.activity.getAllActivitiesTags() + if (walletId == null) tags else tags.filter { it.walletId == walletId } }.onFailure { Logger.error("getAllActivityTags error", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 1130dd23c8..7f207a3949 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -14,6 +14,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,15 +30,18 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isTrezorUserCancellation -import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp +import to.bitkit.ext.walletId import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult @@ -46,6 +50,7 @@ import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.identityKey import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType @@ -56,9 +61,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ceil -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime /** * Production hardware-wallet business layer. Tracks paired Trezor devices as @@ -69,14 +72,12 @@ import kotlin.time.ExperimentalTime * and the underlying watcher transport. */ @Suppress("TooManyFunctions") -@OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, - private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { @@ -95,6 +96,8 @@ class HwWalletRepo @Inject constructor( private val activeWatcherElectrumUrls = mutableMapOf() private val activeWatcherWalletIds = mutableMapOf() private val retryingWatcherStarts = mutableSetOf() + private val initializedWatchers = mutableSetOf() + private val watcherMutex = Mutex() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) private val emittedReceivedTxIds = mutableSetOf() @@ -112,16 +115,19 @@ class HwWalletRepo @Inject constructor( fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId) suspend fun resetState() = withContext(ioDispatcher) { - activeWatchers.toList().forEach { watcherId -> - trezorRepo.stopWatcher(watcherId) - .onFailure { Logger.warn("Failed to stop watcher '$watcherId' while resetting", it, context = TAG) } + watcherMutex.withLock { + activeWatchers.toList().forEach { watcherId -> + trezorRepo.stopWatcher(watcherId) + .onFailure { Logger.warn("Failed to stop watcher '$watcherId' while resetting", it, context = TAG) } + } + activeWatchers.clear() + activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() + retryingWatcherStarts.clear() + initializedWatchers.clear() + emittedReceivedTxIds.clear() + _watcherData.update { emptyMap() } } - activeWatchers.clear() - activeWatcherElectrumUrls.clear() - activeWatcherWalletIds.clear() - retryingWatcherStarts.clear() - emittedReceivedTxIds.clear() - _watcherData.update { emptyMap() } trezorRepo.resetState() } @@ -160,6 +166,15 @@ class HwWalletRepo @Inject constructor( suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(deviceId) + suspend fun getWalletId(deviceId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val target = requireNotNull(hwWalletStore.loadKnownDevices().find { it.id == deviceId }) { + "Unknown hardware wallet '$deviceId'" + } + requireNotNull(target.resolvedWalletId()) { "Hardware wallet '$deviceId' has no wallet id" } + } + } + suspend fun getFundingAccount( deviceId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, @@ -167,7 +182,7 @@ class HwWalletRepo @Inject constructor( runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } - val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + val groupIds = devices.filter { it.identityKey == target.identityKey }.map { it.id }.toSet() val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" } @@ -259,7 +274,7 @@ class HwWalletRepo @Inject constructor( val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } val customLabel = label.trim().take(DEVICE_LABEL_MAX_LENGTH).ifEmpty { null } val updated = devices.map { - if (it.walletKey == target.walletKey) it.copy(customLabel = customLabel) else it + if (it.identityKey == target.identityKey) it.copy(customLabel = customLabel) else it } hwWalletStore.saveKnownDevices(updated) } @@ -272,22 +287,30 @@ class HwWalletRepo @Inject constructor( * single id would leave the tile reappearing through the other transport. */ suspend fun removeDevice(deviceId: String): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val knownDevices = hwWalletStore.loadKnownDevices() val target = knownDevices.find { it.id == deviceId } + val walletId = target?.resolvedWalletId() val ids = when (target) { null -> setOf(deviceId) - else -> knownDevices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + else -> + knownDevices + .filter { it.identityKey == target.identityKey } + .map { it.id } + .toSet() + } + watcherMutex.withLock { + activeWatcherWalletIds.keys.toList() + .filter { it.toDeviceId() in ids } + .forEach { + if (!stopWatcherLocked(it)) throw AppError("Failed to stop hardware wallet watcher '$it'") + } + walletId?.let { activityRepo.deleteForWallet(it).getOrThrow() } + val failures = ids.mapNotNull { trezorRepo.forgetDevice(it).exceptionOrNull() } + val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() + failures.firstOrNull()?.let { throw it } + check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } } - activeWatchers.toList() - .filter { it.toDeviceId() in ids } - .forEach { - if (!stopActiveWatcher(it)) throw AppError("Failed to stop hardware wallet watcher '$it'") - } - val failures = ids.mapNotNull { trezorRepo.forgetDevice(it).exceptionOrNull() } - val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() - failures.firstOrNull()?.let { throw it } - check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } }.onFailure { watcherSyncRequests.tryEmit(Unit) } @@ -303,7 +326,7 @@ class HwWalletRepo @Inject constructor( // identity, so group by them to show one wallet and count its balance once. data.knownDevices .filter { it.xpubs.isNotEmpty() } - .groupBy { it.walletKey } + .groupBy { it.identityKey } .map { (_, devices) -> val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } @@ -358,23 +381,32 @@ class HwWalletRepo @Inject constructor( } private fun observeWatcherEvents() { - scope.launch { + scope.launch(start = CoroutineStart.UNDISPATCHED) { trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect - val previous = _watcherData.value[watcherId] - val activities = event.activities.toImmutableList() - val watcher = HwWatcherData( - deviceId = watcherId.toDeviceId(), - addressType = watcherId.toAddressTypeKey(), - balanceSats = event.balance.total, - activities = activities, - ) - _watcherData.update { it + (watcherId to watcher) } - val updatedWatcherData = _watcherData.value - activities.filterIsInstance().forEach { - activityRepo.syncHardwareOnchainActivity(it.v1) + watcherMutex.withLock { + val walletId = activeWatcherWalletIds[watcherId] ?: return@withLock + val activities = event.activities.filter { it.walletId() == walletId } + val transactionDetails = event.transactionDetails.filter { it.walletId == walletId } + val previous = _watcherData.value[watcherId] + val watcher = HwWatcherData( + deviceId = watcherId.toDeviceId(), + addressType = watcherId.toAddressTypeKey(), + balanceSats = event.balance.total, + activities = activities.toImmutableList(), + ) + _watcherData.update { + it + (watcherId to watcher.copy(activities = previous?.activities ?: persistentListOf())) + } + + val persistedActivities = activityRepo.persistHardware(walletId, activities, transactionDetails) + .getOrElse { return@withLock } + val persistedWatcher = watcher.copy(activities = persistedActivities.toImmutableList()) + val updatedWatcherData = _watcherData.value + (watcherId to persistedWatcher) + _watcherData.update { updatedWatcherData } + if (initializedWatchers.add(watcherId)) return@withLock + emitReceivedTxs(previous, persistedActivities, updatedWatcherData) } - emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -389,20 +421,22 @@ class HwWalletRepo @Inject constructor( watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.mapNotNull { activity -> - (activity as? Activity.Onchain)?.v1?.txId - }.toSet() + val knownActivityIds = previous.activities.map { it.scopedId() }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - activities.filterIsInstance() - .filter { it.v1.txType == PaymentType.RECEIVED } - .forEach { onchain -> - val txid = onchain.v1.txId - if (txid in knownTxIds || !emittedReceivedTxIds.add(txid)) return@forEach - val sats = mergedActivities.findOnchain(txid)?.v1?.value ?: onchain.v1.value - _receivedTxs.emit(HwWalletReceivedTx(txid = txid, sats = sats)) + activities + .filterIsInstance() + .filter { + it.v1.txType == PaymentType.RECEIVED && + it.scopedId() !in knownActivityIds && + emittedReceivedTxIds.add(it.scopedId()) + } + .forEach { + val sats = mergedActivities.findOnchain(it.v1.txId, it.v1.walletId)?.v1?.value ?: it.v1.value + _receivedTxs.emit(HwWalletReceivedTx(txid = it.v1.txId, sats = sats, walletId = it.v1.walletId)) } } + @Suppress("LongMethod") private fun syncWatchers() { scope.launch { val desiredWatchers = combine( @@ -439,49 +473,57 @@ class HwWalletRepo @Inject constructor( }.distinctBy { it.addressType to it.xpub } val filteredIds = filtered.map { it.watcherId }.toSet() - filtered.forEach { spec -> - val isActive = spec.watcherId in activeWatchers - if ( - isActive && - activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && - activeWatcherWalletIds[spec.watcherId] == spec.walletId - ) { - return@forEach - } - if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach - - trezorRepo.startWatcher( - watcherId = spec.watcherId, - extendedKey = spec.xpub, - network = Env.network.toCoreNetwork(), - accountType = spec.addressType.toAddressType()?.toAccountType(), - electrumUrl = spec.electrumUrl, - walletId = spec.walletId, - ).onSuccess { - activeWatchers += spec.watcherId - activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + watcherMutex.withLock { + filtered.forEach { spec -> + val isActive = spec.watcherId in activeWatchers + if ( + isActive && + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && + activeWatcherWalletIds[spec.watcherId] == spec.walletId + ) { + return@forEach + } + if (isActive && !stopWatcherLocked(spec.watcherId)) return@forEach + activeWatcherWalletIds[spec.watcherId] = spec.walletId - retryingWatcherStarts -= spec.watcherId - }.onFailure { - Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) - scheduleWatcherStartRetry(spec.watcherId) + trezorRepo.startWatcher( + watcherId = spec.watcherId, + extendedKey = spec.xpub, + network = Env.network.toCoreNetwork(), + accountType = spec.addressType.toAddressType()?.toAccountType(), + electrumUrl = spec.electrumUrl, + walletId = spec.walletId, + ).onSuccess { + activeWatchers += spec.watcherId + activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + retryingWatcherStarts -= spec.watcherId + }.onFailure { + activeWatcherWalletIds -= spec.watcherId + initializedWatchers -= spec.watcherId + _watcherData.update { it - spec.watcherId } + Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) + scheduleWatcherStartRetry(spec.watcherId) + } } } // A failed stop stays active so the next sync retries it; dropping it here // would leave the orphaned watcher feeding _watcherData as a ghost balance. - (activeWatchers - filteredIds).forEach { staleId -> - stopActiveWatcher(staleId) + watcherMutex.withLock { + (activeWatchers - filteredIds).forEach { staleId -> + stopWatcherLocked(staleId) + } } } } } - private suspend fun stopActiveWatcher(watcherId: String): Boolean = + private suspend fun stopWatcherLocked(watcherId: String): Boolean = trezorRepo.stopWatcher(watcherId).onSuccess { activeWatchers -= watcherId activeWatcherElectrumUrls -= watcherId activeWatcherWalletIds -= watcherId + initializedWatchers -= watcherId _watcherData.update { it - watcherId } }.isSuccess @@ -496,11 +538,11 @@ class HwWalletRepo @Inject constructor( } private fun KnownDevice.resolvedWalletId(): String? = - walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs) + walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs.values).takeIf { it.isNotBlank() } private fun List.toMergedActivities(): List = flatMap { it.activities } - .groupBy { it.rawId() } + .groupBy { it.scopedId() } .values .map { it.mergedActivity() } .sortedByDescending { it.timestamp() } @@ -551,8 +593,8 @@ class HwWalletRepo @Inject constructor( ) } - private fun List.findOnchain(txid: String) = filterIsInstance() - .firstOrNull { it.v1.txId == txid } + private fun List.findOnchain(txid: String, walletId: String) = filterIsInstance() + .firstOrNull { it.v1.txId == txid && it.v1.walletId == walletId } private data class WatcherSpec( val deviceId: String, @@ -574,14 +616,6 @@ private data class WatcherSettings( val electrumUrl: String, ) -/** - * Cross-transport identity of the wallet a device entry tracks: entries created by - * pairing the same physical device over different transports share the same xpubs. - * Entries without captured xpubs fall back to their own transport-level id. - */ -private val KnownDevice.walletKey: String - get() = xpubs.values.sorted().joinToString().ifEmpty { id } - /** * Resolves the name shown for a hardware wallet: the Bitkit-side custom label if the user set one, * otherwise the device's own label; without one (or with the factory default that just mirrors the diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1175d9fcad..f073dea152 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -62,6 +62,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp @@ -88,7 +89,6 @@ import to.bitkit.services.LnurlWithdrawResponse import to.bitkit.services.LspNotificationsService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.UrlValidator @@ -1179,7 +1179,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( - walletId = WalletScope.default, + walletId = DEFAULT_WALLET_ID, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 449a8f56c2..3692dcda0a 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -4,6 +4,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -16,6 +17,7 @@ import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid import to.bitkit.ext.runSuspendCatching @@ -107,6 +109,7 @@ class TransferRepo @Inject constructor( txId: String, fee: ULong, feeRate: ULong, + walletId: String = DEFAULT_WALLET_ID, ): Result = withContext(bgDispatcher) { runSuspendCatching { val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { @@ -120,6 +123,7 @@ class TransferRepo @Inject constructor( feeRate = feeRate, isTransfer = true, channelId = order.channel?.shortChannelId, + walletId = walletId, ) }.onFailure { Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) @@ -260,7 +264,23 @@ class TransferRepo @Inject constructor( } private suspend fun markActivityAsTransfer(txid: String, channelId: String) { - val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + val hardwareActivities = runSuspendCatching { + coreService.activity.get( + walletId = null, + filter = ActivityFilter.ONCHAIN, + ) + }.getOrNull().orEmpty() + .filterIsInstance() + .map { it.v1 } + .filter { it.walletId != DEFAULT_WALLET_ID && it.txId == txid } + + val hardwareTransferActivity = hardwareActivities.firstOrNull { it.isTransfer } + ?: hardwareActivities.singleOrNull { it.txType == PaymentType.SENT } + + val activity = hardwareTransferActivity + ?: coreService.activity.getOnchainActivityByTxId(txid) + ?: return + if (activity.isTransfer && activity.channelId == channelId) return val updated = activity.copy(isTransfer = true, channelId = channelId) coreService.activity.update(activity.id, Activity.Onchain(updated)) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 56a29c92c3..8cbf0c11bc 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -38,7 +38,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -52,6 +51,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -62,12 +62,15 @@ import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.findWalletId import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString import to.bitkit.models.toTrezorCoinType +import to.bitkit.models.withWalletIds import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -77,7 +80,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.TrezorErrorPresenter import java.io.File -import to.bitkit.models.HwWalletId import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -759,8 +761,10 @@ class TrezorRepo @Inject constructor( .any { it.matches(deviceId) && it.transportType == TransportType.BLUETOOTH } } - fun deriveWalletId(xpubs: Map): String? = - deriveHardwareWalletId(xpubs)?.takeIf { it.isNotBlank() } + fun deriveWalletId(xpubs: Collection): String { + if (xpubs.isEmpty() || xpubs.any { it.isBlank() }) return "" + return trezorService.deriveWalletId(ActivityWalletType.TREZOR.id(), xpubs) + } private suspend fun connectedFeatures(deviceId: String): TrezorFeatures? { val current = _state.value.connected @@ -1040,7 +1044,7 @@ class TrezorRepo @Inject constructor( lastConnectedAt = clock.nowMs(), xpubs = xpubs, customLabel = previous?.customLabel, - walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), + walletId = knownDevices.findWalletId(deviceInfo.id, xpubs, ::deriveWalletId), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -1099,9 +1103,9 @@ class TrezorRepo @Inject constructor( } private suspend fun loadKnownDevices(): List = runCatching { - val devices = hwWalletStore.loadKnownDevices() - val migrated = devices.withHardwareWalletIds() - if (migrated != devices) { + val stored = hwWalletStore.loadKnownDevices() + val migrated = stored.withWalletIds(::deriveWalletId) + if (migrated != stored) { hwWalletStore.saveKnownDevices(migrated) } migrated @@ -1320,40 +1324,6 @@ data class ConnectedTrezorDevice( private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId -private val KnownDevice.walletKey: String - get() = walletKey(xpubs, id) - -private fun walletKey(xpubs: Map, fallback: String): String = - xpubs.values.sorted().joinToString().ifEmpty { fallback } - -private fun deriveHardwareWalletId(xpubs: Map): String? = - if (xpubs.isEmpty()) { - null - } else { - runCatching { HwWalletId.derive(xpubs) }.getOrNull() - } - -private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { - val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } - ?: deriveHardwareWalletId(xpubs).orEmpty() -} - -private fun List.withHardwareWalletIds(): List { - val existingByWallet = filter { it.walletId.isNotBlank() } - .associate { it.walletKey to it.walletId } - val generatedByWallet = mutableMapOf() - - return map { - val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { - deriveHardwareWalletId(it.xpubs).orEmpty() - } - if (it.walletId == walletId) it else it.copy(walletId = walletId) - } -} - private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, transportType = transportType.toCoreTransportType(), diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index c5540516b9..a5984dbc4c 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -31,7 +31,9 @@ import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags import com.synonym.bitkitcore.createCjitEntry import com.synonym.bitkitcore.createOrder +import com.synonym.bitkitcore.deleteActivitiesByWalletId import com.synonym.bitkitcore.deleteActivityById +import com.synonym.bitkitcore.deleteTransactionDetails import com.synonym.bitkitcore.deriveOnchainDescriptor import com.synonym.bitkitcore.estimateOrderFeeFull import com.synonym.bitkitcore.getActivities @@ -81,12 +83,16 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.walletId import to.bitkit.models.ALL_ADDRESS_TYPES -import to.bitkit.models.WalletScope import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.msatFloorOf @@ -243,13 +249,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { - private val walletId = WalletScope.default + private val defaultWalletId: String = DEFAULT_WALLET_ID suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( - walletId = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -260,15 +266,18 @@ class ActivityService( sortDirection = null, ) for (activity in activities) { - val id = when (activity) { - is Activity.Lightning -> activity.v1.id - is Activity.Onchain -> activity.v1.id + when (activity) { + is Activity.Lightning -> deleteActivityById(activity.v1.walletId, activity.v1.id) + is Activity.Onchain -> deleteActivityById(activity.v1.walletId, activity.v1.id) } - deleteActivityById(walletId = walletId, activityId = id) } } } + suspend fun deleteByWalletId(walletId: String): UInt = ServiceQueue.CORE.background { + deleteActivitiesByWalletId(walletId) + } + suspend fun insert(activity: Activity) = ServiceQueue.CORE.background { insertActivity(activity) } @@ -281,9 +290,56 @@ class ActivityService( upsertActivities(activities) } + suspend fun upsertTransactionDetailsList(list: List) = ServiceQueue.CORE.background { + upsertTransactionDetails(list) + } + + suspend fun replaceHardwareSnapshot( + walletId: String, + activities: List, + transactionDetails: List, + ): List = ServiceQueue.CORE.background { + val existingActivities = getActivities( + walletId = walletId, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ).filterIsInstance() + val incomingIds = activities.map { it.rawId() }.toSet() + existingActivities + .filter { !it.v1.isTransfer && it.v1.id !in incomingIds } + .forEach { + deleteActivityById(walletId = walletId, activityId = it.v1.id) + deleteTransactionDetails(walletId = walletId, txId = it.v1.txId) + } + + val existingByTxId = existingActivities.associateBy { it.v1.txId } + val mergedActivities = activities.map { activity -> + val onchain = activity as? Activity.Onchain ?: return@map activity + val existing = existingByTxId[onchain.v1.txId]?.v1 ?: return@map activity + if (!existing.isTransfer && existing.channelId == null) return@map activity + + Activity.Onchain( + onchain.v1.copy( + isTransfer = onchain.v1.isTransfer || existing.isTransfer, + channelId = onchain.v1.channelId ?: existing.channelId, + ) + ) + } + if (mergedActivities.isNotEmpty()) upsertActivities(mergedActivities) + if (transactionDetails.isNotEmpty()) upsertTransactionDetails(transactionDetails) + mergedActivities + existingActivities.filter { it.v1.isTransfer && it.v1.id !in incomingIds } + } + private fun mapToCoreTransactionDetails( txid: String, details: TransactionDetails, + walletId: String = defaultWalletId, ): BitkitCoreTransactionDetails { val inputs = details.inputs.map { input -> BitkitCoreTxInput( @@ -312,16 +368,22 @@ class ActivityService( ) } - suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) + suspend fun getTransactionDetails( + txid: String, + walletId: String? = null, + ): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { + getBitkitCoreTransactionDetails(walletId = walletId ?: defaultWalletId, txId = txid) } - suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { - getActivityById(walletId = walletId, activityId = id) + suspend fun getActivity(id: String, walletId: String? = null): Activity? = ServiceQueue.CORE.background { + getActivityById(walletId = walletId ?: defaultWalletId, activityId = id) } - suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { - getActivityByTxId(walletId = walletId, txId = txId) + suspend fun getOnchainActivityByTxId( + txId: String, + walletId: String? = null, + ): OnchainActivity? = ServiceQueue.CORE.background { + getActivityByTxId(walletId = walletId ?: defaultWalletId, txId = txId) } suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { @@ -334,6 +396,7 @@ class ActivityService( @Suppress("LongParameterList") suspend fun get( + walletId: String? = defaultWalletId, filter: ActivityFilter? = null, txType: PaymentType? = null, tags: List? = null, @@ -360,23 +423,32 @@ class ActivityService( updateActivity(activityId = id, activity = activity) } - suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(walletId = walletId, activityId = id) + suspend fun delete(id: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + deleteActivityById(walletId = walletId ?: defaultWalletId, activityId = id) } - suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { + suspend fun appendTags( + toActivityId: String, + tags: List, + walletId: String? = null, + ): Result = runSuspendCatching { ServiceQueue.CORE.background { - addTags(walletId = walletId, activityId = toActivityId, tags = tags) + addTags(walletId = walletId ?: defaultWalletId, activityId = toActivityId, tags = tags) } } - suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(walletId = walletId, activityId = fromActivityId, tags = tags) + suspend fun dropTags( + fromActivityId: String, + tags: List, + walletId: String? = null, + ) = ServiceQueue.CORE.background { + removeTags(walletId = walletId ?: defaultWalletId, activityId = fromActivityId, tags = tags) } - suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(walletId = walletId, activityId = forActivityId) - } + suspend fun tags(forActivityId: String, walletId: String? = null): List = + ServiceQueue.CORE.background { + getTags(walletId = walletId ?: defaultWalletId, activityId = forActivityId) + } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { getAllUniqueTags() @@ -404,7 +476,7 @@ class ActivityService( suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.addPreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, tags = tags, ) @@ -412,14 +484,14 @@ class ActivityService( suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { com.synonym.bitkitcore.removePreActivityMetadataTags( - walletId = walletId, + walletId = defaultWalletId, paymentId = paymentId, tags = tags, ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = defaultWalletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( @@ -427,14 +499,14 @@ class ActivityService( searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { com.synonym.bitkitcore.getPreActivityMetadata( - walletId = walletId, + walletId = defaultWalletId, searchKey = searchKey, searchByAddress = searchByAddress, ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(walletId = walletId, paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = defaultWalletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -537,7 +609,7 @@ class ActivityService( return } - val existingActivity = getActivityById(walletId = walletId, activityId = payment.id) + val existingActivity = getActivityById(walletId = defaultWalletId, activityId = payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -577,7 +649,7 @@ class ActivityService( ) } - if (getActivityById(walletId = walletId, activityId = payment.id) != null) { + if (getActivityById(walletId = defaultWalletId, activityId = payment.id) != null) { updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) @@ -917,24 +989,19 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(walletId = walletId, activityId = payment.id) + var existingActivity = getActivityById(walletId = defaultWalletId, activityId = payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) } } - - val existingOnchainActivity = existingActivity as? Activity.Onchain - if (existingOnchainActivity != null && - (existingOnchainActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && - !(existingOnchainActivity.v1.contact == null && payment.direction == PaymentDirection.INBOUND) - ) { - return + if (existingActivity == null) { + existingActivity = findHardwareTransferByTxId(kind.txid) } + val existingOnchainActivity = existingActivity as? Activity.Onchain var resolvedChannelId = channelId - // Check if this transaction is a channel transfer if (resolvedChannelId == null) { val foundChannelId = findChannelForTransaction( txid = kind.txid, @@ -946,6 +1013,18 @@ class ActivityService( } } + val needsTransferUpdate = existingOnchainActivity != null && + resolvedChannelId != null && + (!existingOnchainActivity.v1.isTransfer || existingOnchainActivity.v1.channelId == null) + + if (existingOnchainActivity != null && + (existingOnchainActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && + !(existingOnchainActivity.v1.contact == null && payment.direction == PaymentDirection.INBOUND) && + !needsTransferUpdate + ) { + return + } + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) val existingContact = existingOnchainActivity?.v1?.contact val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { @@ -989,6 +1068,22 @@ class ActivityService( } } + private fun findHardwareTransferByTxId(txId: String): Activity.Onchain? { + return getActivities( + walletId = null, + filter = ActivityFilter.ONCHAIN, + txType = null, + tags = null, + search = null, + minDate = null, + maxDate = null, + limit = null, + sortDirection = null, + ).filterIsInstance().firstOrNull { + it.v1.walletId != defaultWalletId && it.v1.txId == txId && it.v1.isTransfer + } + } + private fun PaymentDirection.toPaymentType(): PaymentType = if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED @@ -1101,14 +1196,23 @@ class ActivityService( feeRate: ULong, isTransfer: Boolean, channelId: String?, + walletId: String = DEFAULT_WALLET_ID, ) { ServiceQueue.CORE.background { runCatching { - if (getOnchainActivityByTxId(txId = txid) != null) { + val existing = getOnchainActivityByTxId(txId = txid, walletId = walletId) + if (existing != null) { + if (isTransfer) { + val updated = existing.copy( + isTransfer = true, + channelId = existing.channelId ?: channelId, + ) + if (updated != existing) upsertActivity(Activity.Onchain(updated)) + } Logger.debug("Activity already exists for txid $txid, skipping immediate creation", context = TAG) return@background } - val now = System.currentTimeMillis().toULong() / 1000u + val now = nowTimestamp().epochSecond.toULong() val onchain = OnchainActivity.create( id = txid, txType = PaymentType.SENT, @@ -1124,6 +1228,7 @@ class ActivityService( createdAt = now, updatedAt = 0u, seenAt = now, + walletId = walletId, ) upsertActivity(Activity.Onchain(onchain)) Logger.info("Created sent onchain activity for txid $txid from send result", context = TAG) @@ -1416,44 +1521,53 @@ class ActivityService( } } - suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(walletId = walletId, activityId = activityId) ?: return@background false + suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean = ServiceQueue.CORE.background { + val activity = getActivityById(walletId = walletId ?: defaultWalletId, activityId = activityId) + ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null } } - suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(walletId = walletId, activityId = activityId) ?: run { - Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) + suspend fun markActivityAsSeen( + activityId: String, + walletId: String? = null, + seenAt: ULong? = null, + ) = ServiceQueue.CORE.background { + val activity = getActivityById(walletId = walletId ?: defaultWalletId, activityId = activityId) ?: run { + Logger.warn("Skipped marking activity '$activityId' as seen because it was not found", context = TAG) return@background } - val timestamp = seenAt ?: (System.currentTimeMillis().toULong() / 1000u) + val timestamp = seenAt ?: nowTimestamp().epochSecond.toULong() val updatedActivity = when (activity) { is Activity.Lightning -> Activity.Lightning(activity.v1.copy(seenAt = timestamp)) is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp)) } updateActivity(activityId = activityId, activity = updatedActivity) - Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) + Logger.info("Marked activity '$activityId' as seen at '$timestamp'", context = TAG) } - suspend fun markOnchainActivityAsSeen(txid: String, seenAt: ULong? = null) { + suspend fun markOnchainActivityAsSeen( + txid: String, + walletId: String? = null, + seenAt: ULong? = null, + ) { val activity = ServiceQueue.CORE.background { - getOnchainActivityByTxId(txid) + getOnchainActivityByTxId(txid, walletId) } ?: run { - Logger.warn("Cannot mark onchain activity as seen - activity not found for txid: $txid", context = TAG) + Logger.warn("Skipped marking onchain activity '$txid' as seen because it was not found", context = TAG) return } - markActivityAsSeen(activity.id, seenAt) + markActivityAsSeen(activity.id, walletId = activity.walletId, seenAt = seenAt) } suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { - val timestamp = (System.currentTimeMillis() / 1000).toULong() + val timestamp = nowTimestamp().epochSecond.toULong() val activities = getActivities( - walletId = walletId, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -1471,20 +1585,16 @@ class ActivityService( } if (!isSeen) { - val activityId = when (activity) { - is Activity.Onchain -> activity.v1.id - is Activity.Lightning -> activity.v1.id - } - markActivityAsSeen(activityId, timestamp) + markActivityAsSeen(activity.rawId(), walletId = activity.walletId(), seenAt = timestamp) } } } - suspend fun getBoostTxDoesExist(boostTxIds: List): Map { + suspend fun getBoostTxDoesExist(boostTxIds: List, walletId: String): Map { return ServiceQueue.CORE.background { val doesExistMap = mutableMapOf() for (boostTxId in boostTxIds) { - val boostActivity = getOnchainActivityByTxId(boostTxId) + val boostActivity = getOnchainActivityByTxId(boostTxId, walletId) if (boostActivity != null) { doesExistMap[boostTxId] = boostActivity.doesExist && !boostActivity.isBoosted } @@ -1493,21 +1603,22 @@ class ActivityService( } } - suspend fun isCpfpChildTransaction(txId: String): Boolean { + suspend fun isCpfpChildTransaction(txId: String, walletId: String): Boolean { return ServiceQueue.CORE.background { - val txIdsInBoostTxIds = getTxIdsInBoostTxIds() + val txIdsInBoostTxIds = getTxIdsInBoostTxIds(walletId) if (!txIdsInBoostTxIds.contains(txId)) { return@background false } - val activity = getOnchainActivityByTxId(txId) ?: return@background false + val activity = getOnchainActivityByTxId(txId, walletId) ?: return@background false return@background activity.doesExist } } - suspend fun getTxIdsInBoostTxIds(): Set { + suspend fun getTxIdsInBoostTxIds(walletId: String): Set { return ServiceQueue.CORE.background { val allOnchainActivities = get( + walletId = walletId, filter = ActivityFilter.ONCHAIN, txType = null, tags = null, diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 58d55d1034..bfd49160b7 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -44,11 +44,8 @@ class TrezorBridgeTransport( private const val READ_TIMEOUT_MS = 30_000 private const val CALL_READ_TIMEOUT_MS = 120_000 - /** - * Trezor protobuf MessageType_SignTx. This is the only call that waits - * for on-device signing. - */ private const val SIGN_TX_MESSAGE_TYPE = 15 + private const val BUTTON_ACK_MESSAGE_TYPE = 27 } private val json = Json { ignoreUnknownKeys = true } @@ -151,7 +148,13 @@ class TrezorBridgeTransport( return runCatching { val request = encodeFrame(messageType, data) - val timeoutMs = if (messageType == SIGN_TX_MESSAGE_TYPE.toUShort()) callReadTimeoutMs else readTimeoutMs + val timeoutMs = when (messageType) { + SIGN_TX_MESSAGE_TYPE.toUShort(), + BUTTON_ACK_MESSAGE_TYPE.toUShort(), + -> callReadTimeoutMs + + else -> readTimeoutMs + } val response = post("/call/${encode(session)}", request, readTimeoutMs = timeoutMs) decodeFrame(response) }.getOrElse { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9c..535b316b33 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -50,6 +50,7 @@ import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton import com.synonym.bitkitcore.Network as BitkitCoreNetwork +import com.synonym.bitkitcore.deriveWalletId as deriveCoreWalletId @Suppress("TooManyFunctions") @Singleton @@ -301,4 +302,8 @@ class TrezorService @Inject constructor( onchainStopAllWatchers() } } + + fun deriveWalletId(deviceType: String, xpubs: Collection): String { + return deriveCoreWalletId(deviceType = deviceType, xpubs = xpubs.toList()) + } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2b1642a37a..a5765c87a6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -996,7 +996,7 @@ private fun NavGraphBuilder.home( isGeoBlocked = isGeoBlocked, onchainActivities = onchainActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1015,7 +1015,7 @@ private fun NavGraphBuilder.home( channels = lightningState.channels, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { if (!hasSeenSavingsIntro) { @@ -1035,7 +1035,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, @@ -1052,7 +1052,7 @@ private fun NavGraphBuilder.allActivity( AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, - onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1210,7 +1210,7 @@ private fun NavGraphBuilder.contacts( ContactActivityScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onActivityItemClick = { navController.navigateToActivityItem(it) }, + onActivityItemClick = { id, walletId -> navController.navigateToActivityItem(id, walletId) }, ) } } @@ -1594,7 +1594,7 @@ private fun NavGraphBuilder.activityItem( ActivityDetailScreen( listViewModel = activityListViewModel, route = it.toRoute(), - onExploreClick = { id -> navController.navigateToActivityExplore(id) }, + onExploreClick = { id, walletId -> navController.navigateToActivityExplore(id, walletId) }, onAssignContactClick = { id -> navController.navigateTo(Routes.ActivityAssignContact(id)) }, onChannelClick = { channelId -> navController.navigateTo(Routes.ChannelDetail(channelId)) @@ -1851,9 +1851,11 @@ fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) -fun NavController.navigateToActivityItem(id: String) = navigateTo(Routes.ActivityDetail(id)) +fun NavController.navigateToActivityItem(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityDetail(id, walletId)) -fun NavController.navigateToActivityExplore(id: String) = navigateTo(Routes.ActivityExplore(id)) +fun NavController.navigateToActivityExplore(id: String, walletId: String? = null) = + navigateTo(Routes.ActivityExplore(id, walletId)) fun NavController.navigateToLogDetail(fileName: String) = navigateTo(Routes.LogDetail(fileName)) @@ -2074,13 +2076,13 @@ sealed interface Routes { data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes @Serializable - data class ActivityDetail(val id: String) : Routes + data class ActivityDetail(val id: String, val walletId: String? = null) : Routes @Serializable data class ActivityAssignContact(val id: String) : Routes @Serializable - data class ActivityExplore(val id: String) : Routes + data class ActivityExplore(val id: String, val walletId: String? = null) : Routes @Serializable data object BuyIntro : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt index 80d8c507f5..8cc2dead5d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -37,7 +37,7 @@ import to.bitkit.ui.theme.Colors fun ContactActivityScreen( viewModel: ContactActivityViewModel, onBackClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -54,7 +54,7 @@ private fun Content( uiState: ContactActivityUiState, onBackClick: () -> Unit, onRetryClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { ScreenColumn { AppTopBar( @@ -118,7 +118,7 @@ private fun ErrorState( private fun ContactActivityList( profile: PubkyProfile?, activities: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, modifier: Modifier = Modifier, ) { val name = profile?.name @@ -154,7 +154,7 @@ private fun Preview() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -170,7 +170,7 @@ private fun PreviewEmpty() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -186,7 +186,7 @@ private fun PreviewError() { ), onBackClick = {}, onRetryClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 6c9f0f831e..5cbfad1684 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.models.HwWalletId import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork @@ -713,15 +712,13 @@ class TrezorViewModel @Inject constructor( ) ) } - val walletId = runCatching { HwWalletId.derive(mapOf("watcher" to key)) } - .getOrDefault("trezor:watcher") val result = trezorRepo.startWatcher( watcherId = watcherId, + walletId = trezorRepo.deriveWalletId(listOf(key)), extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, accountType = state.watcher.selectedAccountType, - walletId = walletId, ) if (result.isSuccess) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 9b1d45116f..968ff6c63e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -39,7 +39,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.models.HwWallet import to.bitkit.models.TransportType import to.bitkit.ui.components.BalanceHeaderView @@ -62,7 +62,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( deviceId: String, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), @@ -95,7 +95,7 @@ fun HardwareWalletScreen( private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, @@ -109,7 +109,7 @@ private fun HardwareWalletContent( // Every activity here belongs to the watch-only device, so render them all with the blue // hardware icon, matching the home list. - val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.rawId() }.toImmutableSet() } + val hardwareIds = remember(wallet.activities) { wallet.activities.map { it.scopedId() }.toImmutableSet() } val hazeState = rememberHazeState() @@ -264,7 +264,7 @@ private fun Preview() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -284,7 +284,7 @@ private fun PreviewNoActivity() { HardwareWalletContent( wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -304,7 +304,7 @@ private fun PreviewEmpty() { HardwareWalletContent( wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, @@ -324,7 +324,7 @@ private fun PreviewRemoveDialog() { HardwareWalletContent( wallet = previewWallet(), showRemoveDialog = true, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index cc3ea5f20d..083121d0c0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -379,7 +379,7 @@ fun HomeScreen( onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, onNavigateToSettingUp = { rootNavController.navigate(Routes.SettingUp) }, onNavigateToAllActivity = { rootNavController.navigateToAllActivity(activityListViewModel::clearFilters) }, - onNavigateToActivityItem = { rootNavController.navigateToActivityItem(it) }, + onNavigateToActivityItem = { id, walletId -> rootNavController.navigateToActivityItem(id, walletId) }, onNavigateToSavings = { walletNavController.navigate(Routes.Savings) }, onNavigateToSpending = { walletNavController.navigate(Routes.Spending) }, onClickHardwareWallet = { walletNavController.navigateTo(Routes.HardwareWallet(it)) }, @@ -417,7 +417,7 @@ private fun Content( onNavigateToAppStatus: () -> Unit = {}, onNavigateToSettingUp: () -> Unit = {}, onNavigateToAllActivity: () -> Unit = {}, - onNavigateToActivityItem: (String) -> Unit = {}, + onNavigateToActivityItem: (String, String) -> Unit = { _, _ -> }, onNavigateToSavings: () -> Unit = {}, onNavigateToSpending: () -> Unit = {}, onClickHardwareWallet: (String) -> Unit = {}, @@ -588,7 +588,7 @@ private fun WalletPage( onRefresh: () -> Unit, onNavigateToSettingUp: () -> Unit, onNavigateToAllActivity: () -> Unit, - onNavigateToActivityItem: (String) -> Unit, + onNavigateToActivityItem: (String, String) -> Unit, onNavigateToSavings: () -> Unit, onNavigateToSpending: () -> Unit, onClickHardwareWallet: (String) -> Unit, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 45efea812a..368aabc417 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -62,7 +62,7 @@ fun SavingsWalletScreen( onchainActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, onEmptyActivityRowClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, forceCloseRemainingDuration: String? = null, @@ -200,7 +200,7 @@ private fun Preview() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -220,7 +220,7 @@ private fun PreviewTransfer() { isGeoBlocked = false, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -243,7 +243,7 @@ private fun PreviewNoActivity() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -263,7 +263,7 @@ private fun PreviewGeoBlocked() { isGeoBlocked = true, onchainActivities = previewOnchainActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, @@ -283,7 +283,7 @@ private fun PreviewEmpty() { isGeoBlocked = false, onchainActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSpendingClick = {}, onBackClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 2cedaeafb5..dbb8f3aaac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -63,7 +63,7 @@ fun SpendingWalletScreen( channels: ImmutableList, lightningActivities: ImmutableList, onAllActivityButtonClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, @@ -223,7 +223,7 @@ private fun Preview() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -244,7 +244,7 @@ private fun PreviewTransfer() { channels = persistentListOf(createChannelDetails()), lightningActivities = previewLightningActivityItems(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -268,7 +268,7 @@ private fun PreviewNoActivity() { channels = persistentListOf(createChannelDetails()), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -289,7 +289,7 @@ private fun PreviewEmpty() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, @@ -309,7 +309,7 @@ private fun PreviewEmptyWithSavings() { channels = persistentListOf(), lightningActivities = persistentListOf(), onAllActivityButtonClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, onTransferToSavingsClick = {}, onTransferFromSavingsClick = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 2a295347c0..74068f3c06 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -58,6 +58,7 @@ import to.bitkit.R import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId @@ -65,6 +66,7 @@ import to.bitkit.ext.timestamp import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -106,7 +108,7 @@ fun ActivityDetailScreen( listViewModel: ActivityListViewModel, detailViewModel: ActivityDetailViewModel = hiltViewModel(), route: Routes.ActivityDetail, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onAssignContactClick: (String) -> Unit, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -115,8 +117,8 @@ fun ActivityDetailScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -178,6 +180,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity + val isHardware = remember(item.walletId()) { item.isFromHardwareWallet() } val app = appViewModel ?: return@Box val settings = settingsViewModel ?: return@Box val hideBalance by settings.hideBalance.collectAsStateWithLifecycle() @@ -193,9 +196,9 @@ fun ActivityDetailScreen( LaunchedEffect(item) { if (item is Activity.Onchain) { - isCpfpChild = detailViewModel.isCpfpChildTransaction(item.v1.txId) + isCpfpChild = detailViewModel.isCpfpChildTransaction(item.v1.txId, item.v1.walletId) boostTxDoesExist = if (item.v1.boostTxIds.isNotEmpty()) { - detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds, item.v1.walletId) } else { persistentMapOf() } @@ -208,7 +211,7 @@ fun ActivityDetailScreen( // Update boostTxDoesExist when boostTxIds change LaunchedEffect(if (item is Activity.Onchain) item.v1.boostTxIds else emptyList()) { if (item is Activity.Onchain && item.v1.boostTxIds.isNotEmpty()) { - boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds, item.v1.walletId) } } @@ -246,8 +249,8 @@ fun ActivityDetailScreen( onChannelClick = onChannelClick, detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, - isHardware = uiState.isHardwareActivity, - showContactActions = isPaykitEnabled && !uiState.isHardwareActivity, + isHardware = isHardware, + showContactActions = isPaykitEnabled && !isHardware, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -325,7 +328,7 @@ private fun ActivityDetailContent( onAssignClick: () -> Unit, onDetachClick: () -> Unit, onClickBoost: () -> Unit, - onExploreClick: (String) -> Unit, + onExploreClick: (String, String) -> Unit, onChannelClick: ((String) -> Unit)?, detailViewModel: ActivityDetailViewModel? = null, isCpfpChild: Boolean = false, @@ -598,7 +601,7 @@ private fun ActivityDetailContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - val showTagAction = !isHardware + val showTagAction = true if (showContactActions || showTagAction) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -705,7 +708,7 @@ private fun ActivityDetailContent( PrimaryButton( text = stringResource(R.string.wallet__activity_explore), size = ButtonSize.Small, - onClick = { onExploreClick(item.rawId()) }, + onClick = { onExploreClick(item.rawId(), item.walletId()) }, icon = { Icon( painter = painterResource(R.drawable.ic_git_branch), @@ -998,7 +1001,7 @@ private fun PreviewLightningSent() { onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {} @@ -1031,7 +1034,7 @@ private fun PreviewOnchain() { onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, @@ -1065,7 +1068,7 @@ private fun PreviewSheetSmallScreen() { onAddTagClick = {}, onAssignClick = {}, onDetachClick = {}, - onExploreClick = {}, + onExploreClick = { _, _ -> }, onChannelClick = null, onCopy = {}, onClickBoost = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index 45f2469ae4..24f4f062fb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -44,6 +44,7 @@ import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.isSent import to.bitkit.ext.totalValue import to.bitkit.models.Toast @@ -75,8 +76,8 @@ fun ActivityExploreScreen( val uiState by detailViewModel.uiState.collectAsStateWithLifecycle() // Load activity on composition - LaunchedEffect(route.id) { - detailViewModel.loadActivity(route.id) + LaunchedEffect(route.id, route.walletId) { + detailViewModel.loadActivity(route.id, route.walletId) } // Clear state on disposal @@ -142,7 +143,7 @@ fun ActivityExploreScreen( if (item is Activity.Onchain) { detailViewModel.fetchTransactionDetails(item.v1.txId) if (item.v1.boostTxIds.isNotEmpty()) { - boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds) + boostTxDoesExist = detailViewModel.getBoostTxDoesExist(item.v1.boostTxIds, item.v1.walletId) } } else { detailViewModel.clearTransactionDetails() @@ -164,7 +165,6 @@ fun ActivityExploreScreen( val toastMessage = stringResource(R.string.common__copied) ActivityExploreContent( item = item, - isHardware = uiState.isHardwareActivity, txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> @@ -188,7 +188,6 @@ fun ActivityExploreScreen( @Composable private fun ActivityExploreContent( item: Activity, - isHardware: Boolean = false, txDetails: TransactionDetails? = null, boostTxDoesExist: Map = emptyMap(), onCopy: (String) -> Unit = {}, @@ -212,7 +211,11 @@ private fun ActivityExploreContent( showBitcoinSymbol = false, modifier = Modifier.weight(1f), ) - ActivityIcon(activity = item, size = 48.dp, isHardware = isHardware) + ActivityIcon( + activity = item, + size = 48.dp, + isHardware = item.isFromHardwareWallet(), + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -221,7 +224,6 @@ private fun ActivityExploreContent( is Activity.Onchain -> { OnchainDetails( onchain = item, - isHardware = isHardware, onCopy = onCopy, txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, @@ -284,7 +286,6 @@ private fun LightningDetails( @Composable private fun ColumnScope.OnchainDetails( onchain: Activity.Onchain, - isHardware: Boolean, onCopy: (String) -> Unit, txDetails: TransactionDetails?, boostTxDoesExist: Map = emptyMap(), @@ -327,7 +328,7 @@ private fun ColumnScope.OnchainDetails( } }, ) - } else if (!isHardware && !onchain.v1.isTransfer) { + } else if (!onchain.v1.isTransfer) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index e05492d000..a596ab12a0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -40,7 +40,7 @@ import to.bitkit.viewmodels.ActivityListViewModel fun AllActivityScreen( viewModel: ActivityListViewModel, onBack: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, ) { val app = appViewModel ?: return val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() @@ -90,7 +90,7 @@ private fun AllActivityScreenContent( onBackClick: () -> Unit, onTagClick: () -> Unit, onDateRangeClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { val listState = rememberLazyListState() @@ -167,7 +167,7 @@ private fun Preview() { onBackClick = {}, onTagClick = {}, onDateRangeClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onRemoveTag = {}, onEmptyActivityRowClick = {}, ) @@ -192,7 +192,7 @@ private fun PreviewEmpty() { onTagClick = {}, onDateRangeClick = {}, onRemoveTag = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 6c4394e81b..77e086c372 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -28,7 +28,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up @@ -47,7 +47,7 @@ import java.util.Locale @Composable fun ActivityListGrouped( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), @@ -81,11 +81,8 @@ fun ActivityListGrouped( key = { index, item -> when (item) { is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" else -> "item_$index" } } @@ -120,7 +117,7 @@ fun ActivityListGrouped( onClick = onActivityItemClick, testTag = "$activityTestTagPrefix-$index", title = titleProvider(item) ?: contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = if (showContactAvatar) contactForActivity(item, contacts) else null, ) VerticalSpacer(16.dp) @@ -165,7 +162,7 @@ fun ActivityListGrouped( @Suppress("LongMethod", "LongParameterList") fun LazyListScope.activityListGroupedItems( items: ImmutableList?, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, onEmptyActivityRowClick: () -> Unit, showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, @@ -179,11 +176,8 @@ fun LazyListScope.activityListGroupedItems( key = { index, item -> when (item) { is String -> "header_$item" - is Activity -> when (item) { - is Activity.Lightning -> "lightning_${item.rawId()}" - is Activity.Onchain -> "onchain_${item.rawId()}" - } - + is Activity.Lightning -> "lightning_${item.scopedId()}" + is Activity.Onchain -> "onchain_${item.scopedId()}" else -> "item_$index" } }, @@ -217,7 +211,7 @@ fun LazyListScope.activityListGroupedItems( item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, ) VerticalSpacer(16.dp) } @@ -334,7 +328,7 @@ private fun Preview() { Column(modifier = Modifier.padding(horizontal = 16.dp)) { ActivityListGrouped( items = previewActivityItems, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -347,7 +341,7 @@ private fun PreviewEmpty() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, ) } @@ -359,7 +353,7 @@ private fun PreviewEmptyWithFooter() { AppThemeSurface { ActivityListGrouped( items = persistentListOf(), - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, onEmptyActivityRowClick = {}, showFooter = true, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 5af0ba3173..b20bb4c06b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import to.bitkit.R -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer @@ -32,7 +32,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun ActivityListSimple( items: ImmutableList?, onAllActivityClick: () -> Unit, - onActivityItemClick: (String) -> Unit, + onActivityItemClick: (String, String) -> Unit, hardwareIds: ImmutableSet = persistentSetOf(), ) { if (items.isNullOrEmpty()) return @@ -51,7 +51,7 @@ fun ActivityListSimple( onClick = onActivityItemClick, testTag = "ActivityShort-$index", title = contactActivityTitle(item, contacts), - isHardware = item.rawId() in hardwareIds, + isHardware = item.scopedId() in hardwareIds, contact = contactForActivity(item, contacts), ) if (index < items.lastIndex) { @@ -76,7 +76,7 @@ private fun Preview() { ActivityListSimple( items = previewActivityItems, onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } @@ -88,7 +88,7 @@ private fun PreviewEmpty() { ActivityListSimple( items = persistentListOf(), onAllActivityClick = {}, - onActivityItemClick = {}, + onActivityItemClick = { _, _ -> }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a26a324811..9033b4258d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -36,6 +36,7 @@ import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -65,7 +66,7 @@ import java.time.ZoneId @Composable fun ActivityRow( item: Activity, - onClick: (String) -> Unit, + onClick: (String, String) -> Unit, testTag: String, title: String? = null, isHardware: Boolean = false, @@ -101,7 +102,7 @@ fun ActivityRow( LaunchedEffect(item) { isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { - activityListViewModel.isCpfpChildTransaction(item.v1.txId) + activityListViewModel.isCpfpChildTransaction(item.v1.txId, item.v1.walletId) } else { false } @@ -111,7 +112,7 @@ fun ActivityRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickableAlpha { onClick(item.rawId()) } + .clickableAlpha { onClick(item.rawId(), item.walletId()) } .background(color = Colors.Gray6, shape = Shapes.medium) .padding(16.dp) .testTag(testTag) @@ -387,7 +388,7 @@ private fun Preview(@PreviewParameter(ActivityItemsPreviewProvider::class) item: AppThemeSurface { ActivityRow( item = item, - onClick = {}, + onClick = { _, _ -> }, testTag = "Activity-", ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt index a09633a4f2..54c9f9bf7b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailViewModel.kt @@ -144,6 +144,7 @@ class ChannelDetailViewModel @Inject constructor( private fun fetchActivityTimestamp(channelId: String) = viewModelScope.launch { val activities = activityRepo.getActivities( + walletId = null, filter = ActivityFilter.ONCHAIN, txType = PaymentType.SENT, ).getOrNull().orEmpty() diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index ec8c49ea6f..747ead38d1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -25,9 +25,9 @@ import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId +import to.bitkit.ext.walletId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import javax.inject.Inject @@ -40,7 +40,6 @@ class ActivityDetailViewModel @Inject constructor( private val activityRepo: ActivityRepo, private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, - private val hwWalletRepo: HwWalletRepo, private val transferRepo: TransferRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) @@ -58,23 +57,28 @@ class ActivityDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ActivityDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun loadActivity(activityId: String) { + fun loadActivity(activityId: String, walletId: String? = null) { viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(activityLoadState = ActivityLoadState.Loading) } - activityRepo.getActivity(activityId) + activityRepo.getActivity(activityId, walletId) .onSuccess { activity -> if (activity != null) { this@ActivityDetailViewModel.activity = activity _uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) } loadTags() - observeActivityChanges(activityId) + observeActivityChanges(activityId, walletId) } else { - loadHwWalletActivity(activityId) + _uiState.update { + it.copy( + activityLoadState = ActivityLoadState.Error( + context.getString(R.string.wallet__activity_error_not_found) + ) + ) + } } } .onFailure { e -> - Logger.error("Failed to load activity $activityId", e, TAG) _uiState.update { it.copy( activityLoadState = ActivityLoadState.Error( @@ -89,57 +93,22 @@ class ActivityDetailViewModel @Inject constructor( fun clearActivityState() { observeJob?.cancel() observeJob = null - _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial, isHardwareActivity = false) } + _uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) } activity = null _tags.update { persistentListOf() } } - private fun loadHwWalletActivity(activityId: String) { - val hwActivity = hwWalletRepo.activities.value.find { it.rawId() == activityId } - if (hwActivity != null) { - activity = hwActivity - _uiState.update { - it.copy(activityLoadState = ActivityLoadState.Success(hwActivity), isHardwareActivity = true) - } - observeHwWalletActivityChanges(activityId) - } else { - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Error( - context.getString(R.string.wallet__activity_error_not_found) - ) - ) - } - } - } - - private fun observeHwWalletActivityChanges(activityId: String) { - observeJob?.cancel() - observeJob = viewModelScope.launch(bgDispatcher) { - hwWalletRepo.activities.collect { activities -> - val updatedActivity = activities.find { it.rawId() == activityId } ?: return@collect - activity = updatedActivity - _uiState.update { - it.copy( - activityLoadState = ActivityLoadState.Success(updatedActivity), - isHardwareActivity = true, - ) - } - } - } - } - - private fun observeActivityChanges(activityId: String) { + private fun observeActivityChanges(activityId: String, walletId: String?) { observeJob?.cancel() observeJob = viewModelScope.launch(bgDispatcher) { activityRepo.activitiesChanged.collect { - reloadActivity(activityId) + reloadActivity(activityId, walletId) } } } - private suspend fun reloadActivity(activityId: String) { - activityRepo.getActivity(activityId) + private suspend fun reloadActivity(activityId: String, walletId: String?) { + activityRepo.getActivity(activityId, walletId) .onSuccess { updatedActivity -> if (updatedActivity != null) { activity = updatedActivity @@ -149,21 +118,17 @@ class ActivityDetailViewModel @Inject constructor( loadTags() } } - .onFailure { error -> - Logger.warn("Failed to reload activity $activityId", error, context = TAG) - // Keep showing the last known state on reload failure - } } fun loadTags() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getActivityTags(id) + activityRepo.getActivityTags(id, walletId) .onSuccess { activityTags -> _tags.update { activityTags.toImmutableList() } } .onFailure { - Logger.error("Failed to load tags for activity $id", it, TAG) _tags.update { persistentListOf() } } } @@ -171,48 +136,44 @@ class ActivityDetailViewModel @Inject constructor( fun removeTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.removeTagsFromActivity(id, listOf(tag)) + activityRepo.removeTagsFromActivity(id, listOf(tag), walletId) .onSuccess { loadTags() } - .onFailure { - Logger.error("Failed to remove tag $tag from activity $id", it, TAG) - } } } fun addTag(tag: String) { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.addTagsToActivity(id, listOf(tag)) + activityRepo.addTagsToActivity(id, listOf(tag), walletId) .onSuccess { settingsStore.addLastUsedTag(tag) loadTags() } - .onFailure { - Logger.error("Failed to add tag $tag to activity $id", it, TAG) - } } } fun detachContact() { val id = activity?.rawId() ?: return + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, ).onSuccess { - reloadActivity(id) - }.onFailure { - Logger.error("Failed to detach contact for activity '$id'", it, context = TAG) + reloadActivity(id, walletId) } } } fun fetchTransactionDetails(txid: String) { + val walletId = activity?.walletId() viewModelScope.launch(bgDispatcher) { - activityRepo.getTransactionDetails(txid) + activityRepo.getTransactionDetails(txid, walletId) .onSuccess { transactionDetails -> _txDetails.update { transactionDetails } } @@ -235,11 +196,11 @@ class ActivityDetailViewModel @Inject constructor( _boostSheetVisible.update { false } } - suspend fun getBoostTxDoesExist(boostTxIds: List): ImmutableMap = - activityRepo.getBoostTxDoesExist(boostTxIds).toImmutableMap() + suspend fun getBoostTxDoesExist(boostTxIds: List, walletId: String): ImmutableMap = + activityRepo.getBoostTxDoesExist(boostTxIds, walletId).toImmutableMap() - suspend fun isCpfpChildTransaction(txId: String): Boolean { - return activityRepo.isCpfpChildTransaction(txId) + suspend fun isCpfpChildTransaction(txId: String, walletId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId, walletId) } suspend fun findOrderForTransfer( @@ -286,6 +247,5 @@ class ActivityDetailViewModel @Inject constructor( data class ActivityDetailUiState( val activityLoadState: ActivityLoadState = ActivityLoadState.Initial, - val isHardwareActivity: Boolean = false, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index df932631a0..5f572e6d19 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,15 +27,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isFromHardwareWallet import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.isTransfer -import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp import to.bitkit.ext.txType +import to.bitkit.ext.walletId import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -46,7 +47,6 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, - private val hwWalletRepo: HwWalletRepo, pubkyRepo: PubkyRepo, settingsStore: SettingsStore, ) : ViewModel() { @@ -60,25 +60,9 @@ class ActivityListViewModel @Inject constructor( val onchainActivities = _onchainActivities.asStateFlow() private val _latestActivities = MutableStateFlow?>(null) - private val _localActivityIds = MutableStateFlow>(emptySet()) - - // Merge the device's watch-only hardware-wallet activity into the home list, - // newest first, capped at the same limit as the on-chain/lightning list. - val latestActivities: StateFlow?> = combine( - _latestActivities, - hwWalletRepo.activities, - _localActivityIds, - ) { localActivities, hardwareActivities, localActivityIds -> - val visibleHardwareActivities = hardwareActivities.withoutLocalDuplicates(localActivityIds) - if (localActivities == null && visibleHardwareActivities.isEmpty()) { - null - } else { - (localActivities.orEmpty() + visibleHardwareActivities) - .sortedByDescending { it.timestamp() } - .take(SIZE_LATEST) - .toImmutableList() - } - }.stateInScope(null) + val latestActivities: StateFlow?> = _latestActivities.asStateFlow() + + private val _hardwareIds = MutableStateFlow>(persistentSetOf()) val contacts: StateFlow> = combine( @@ -91,15 +75,7 @@ class ActivityListViewModel @Inject constructor( val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) - val hardwareIds: StateFlow> = combine( - hwWalletRepo.activities, - _localActivityIds, - ) { activities, localActivityIds -> - activities.withoutLocalDuplicates(localActivityIds) - .map { it.rawId() } - .toImmutableSet() - } - .stateInScope(persistentSetOf()) + val hardwareIds: StateFlow> = _hardwareIds.asStateFlow() private val _filters = MutableStateFlow(ActivityFilters()) @@ -143,55 +119,24 @@ class ActivityListViewModel @Inject constructor( _filters.map { it.searchText }.debounce(300), _filters.map { it.copy(searchText = "") }, activityRepo.activitiesChanged, - hwWalletRepo.activities, - _localActivityIds, - ) { debouncedSearch, filtersWithoutSearch, _, hardwareActivities, localActivityIds -> + ) { debouncedSearch, filtersWithoutSearch, _ -> val filters = filtersWithoutSearch.copy(searchText = debouncedSearch) - fetchFilteredActivities(filters)?.let { activities -> - (activities + hardwareActivities.withoutLocalDuplicates(localActivityIds).filteredWith(filters)) - .sortedByDescending { it.timestamp() } - } + fetchFilteredActivities(filters)?.sortedByDescending { it.timestamp() } }.collect { activities -> _filteredActivities.update { activities?.toImmutableList() } } } - /** - * Watch-only hardware-wallet activities live outside the activity database, so the - * list filters are applied to them here. They carry no tags and are never transfers. - */ - private fun List.filteredWith(filters: ActivityFilters): List { - if (filters.tags.isNotEmpty() || filters.tab == ActivityTab.OTHER) return emptyList() - - val minTimestamp = filters.startDate?.let { (it / 1000).toULong() } - val maxTimestamp = filters.endDate?.let { (it / 1000).toULong() } - - return filter { activity -> - val matchesTab = when (filters.tab) { - ActivityTab.SENT -> activity.txType() == PaymentType.SENT - ActivityTab.RECEIVED -> activity.txType() == PaymentType.RECEIVED - else -> true - } - val matchesSearch = filters.searchText.isEmpty() || - activity.rawId().contains(filters.searchText, ignoreCase = true) - val timestamp = activity.timestamp() - val matchesDate = (minTimestamp == null || timestamp >= minTimestamp) && - (maxTimestamp == null || timestamp <= maxTimestamp) - matchesTab && matchesSearch && matchesDate - } - } - - private fun List.withoutLocalDuplicates(localActivityIds: Set) = filterNot { - it.rawId() in localActivityIds - } - private suspend fun refreshActivityState() { - val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() + val all = activityRepo.getActivities(walletId = null, filter = ActivityFilter.ALL).getOrNull() ?: emptyList() val filtered = filterOutReplacedSentTransactions(all) - _localActivityIds.update { filtered.map { it.rawId() }.toSet() } + val bitkitActivities = filtered.filterNot { it.isFromHardwareWallet() } + _hardwareIds.update { + filtered.filter { it.isFromHardwareWallet() }.map { it.scopedId() }.toImmutableSet() + } _latestActivities.update { filtered.take(SIZE_LATEST).toImmutableList() } - _lightningActivities.update { filtered.filterIsInstance().toImmutableList() } - _onchainActivities.update { filtered.filterIsInstance().toImmutableList() } + _lightningActivities.update { bitkitActivities.filterIsInstance().toImmutableList() } + _onchainActivities.update { bitkitActivities.filterIsInstance().toImmutableList() } } private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { @@ -202,6 +147,7 @@ class ActivityListViewModel @Inject constructor( } val activities = activityRepo.getActivities( + walletId = null, filter = ActivityFilter.ALL, txType = txType, tags = filters.tags.takeIf { it.isNotEmpty() }?.toList(), @@ -223,8 +169,13 @@ class ActivityListViewModel @Inject constructor( } private suspend fun filterOutReplacedSentTransactions(activities: List): List { - val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds() - return activities.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) } + val txIdsByWallet = activities + .map { it.walletId() } + .distinct() + .associateWith { activityRepo.getTxIdsInBoostTxIds(it) } + return activities.filterNot { + it.isReplacedSentTransaction(txIdsByWallet[it.walletId()].orEmpty()) + } } fun updateAvailableTags() { @@ -251,8 +202,8 @@ class ActivityListViewModel @Inject constructor( activityRepo.removeAllActivities() } - suspend fun isCpfpChildTransaction(txId: String): Boolean { - return activityRepo.isCpfpChildTransaction(txId) + suspend fun isCpfpChildTransaction(txId: String, walletId: String): Boolean { + return activityRepo.isCpfpChildTransaction(txId, walletId) } private fun Flow.stateInScope( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dd7217c838..531647f061 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -96,6 +96,7 @@ import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage import to.bitkit.ext.totalValue +import to.bitkit.ext.walletId import to.bitkit.ext.watchUntil import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.FeeRate @@ -335,6 +336,7 @@ class AppViewModel @Inject constructor( direction = NewTransactionSheetDirection.RECEIVED, paymentHashOrTxId = tx.txid, activityId = tx.txid, + activityWalletId = tx.walletId, sats = tx.sats.toLong(), ), ) @@ -2442,8 +2444,9 @@ class AppViewModel @Inject constructor( fun onClickActivityDetail() { _transactionSheet.value.activityId?.let { + val walletId = _transactionSheet.value.activityWalletId hideNewTransactionSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(it, walletId))) return } @@ -2460,7 +2463,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideNewTransactionSheet() _transactionSheet.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) @@ -2484,7 +2487,7 @@ class AppViewModel @Inject constructor( ).onSuccess { activity -> hideSheet() _successSendUiState.update { it.copy(isLoadingDetails = false) } - val nextRoute = Routes.ActivityDetail(activity.rawId()) + val nextRoute = Routes.ActivityDetail(activity.rawId(), activity.walletId()) mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 05aa1c171e..af3d9725d3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.amountOnClose import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.ext.isTrezorUserCancellation @@ -203,6 +204,7 @@ class TransferViewModel @Inject constructor( } /** Pays for the order and start watching it for state updates */ + @Suppress("LongMethod") fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) { viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() @@ -278,6 +280,7 @@ class TransferViewModel @Inject constructor( createTransferActivity: Boolean = false, fee: ULong = 0uL, feeRate: ULong = 0uL, + walletId: String = DEFAULT_WALLET_ID, txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, ) { @@ -296,6 +299,7 @@ class TransferViewModel @Inject constructor( txId = txId, fee = fee, feeRate = feeRate, + walletId = walletId, ) } viewModelScope.launch { walletRepo.syncBalances() } @@ -541,6 +545,10 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) return@launch } + val walletId = hwWalletRepo.getWalletId(deviceId).getOrElse { + handleHardwareTransferFailure(it, deviceId) + return@launch + } signTransferToSpendingWithHardware(order, deviceId, address) .onSuccess { result -> @@ -550,6 +558,7 @@ class TransferViewModel @Inject constructor( createTransferActivity = true, fee = result.miningFeeSats, feeRate = result.feeRate, + walletId = walletId, ) setTransferEffect(TransferEffect.OnHwTxSigned) } @@ -583,6 +592,7 @@ class TransferViewModel @Inject constructor( return result } + @Suppress("ThrowsCount") private suspend fun ensureHardwareConnected(deviceId: String) { runCatching { withTimeout(HW_RECONNECT_TIMEOUT) { diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 1c935765f6..2402f8bd3a 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -73,7 +73,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -85,7 +85,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) - verify(activityRepo).markActivityAsSeen("paymentId123") + verify(activityRepo).markActivityAsSeen("paymentId123", null) } @Test @@ -95,7 +95,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning( event = event, includeNotification = true, @@ -133,7 +133,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) - verify(activityRepo).markOnchainActivityAsSeen("txid456") + verify(activityRepo).markOnchainActivityAsSeen("txid456", null) } @Test @@ -172,7 +172,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) verify(activityRepo).shouldShowReceivedSheet("txid789", 7500uL) - verify(activityRepo).markOnchainActivityAsSeen("txid789") + verify(activityRepo).markOnchainActivityAsSeen("txid789", null) } } @@ -190,7 +190,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut(command) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), anyOrNull()) } @Test @@ -200,14 +200,14 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen(any())).thenReturn(false) + whenever(activityRepo.isActivitySeen(any(), anyOrNull())).thenReturn(false) val command = NotifyPaymentReceived.Command.Lightning(event = event) sut(command) verify(activityRepo, never()).handleOnchainTransactionReceived(any(), any()) verify(activityRepo, never()).shouldShowReceivedSheet(any(), any()) - verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + verify(activityRepo, never()).markOnchainActivityAsSeen(any(), anyOrNull()) } @Test @@ -217,7 +217,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { on { paymentHash } doReturn "hash123" on { paymentId } doReturn "paymentId123" } - whenever(activityRepo.isActivitySeen("paymentId123")).thenReturn(true) + whenever(activityRepo.isActivitySeen("paymentId123", null)).thenReturn(true) val command = NotifyPaymentReceived.Command.Lightning(event = event) val result = sut(command) @@ -225,7 +225,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertTrue(result.isSuccess) val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) - verify(activityRepo, never()).markActivityAsSeen(any()) + verify(activityRepo, never()).markActivityAsSeen(any(), anyOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index 1900be86da..2fd09356a8 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -1,10 +1,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.TrezorException +import org.junit.Test import to.bitkit.utils.AppError import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.junit.Test class TrezorExceptionExtTest { @Test diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index caf7b134f4..1ddd121fc5 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -12,18 +12,21 @@ import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import to.bitkit.test.BaseUnitTest import to.bitkit.viewmodels.ActivityDetailViewModel import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -34,8 +37,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val activityRepo = mock() private val blocktankRepo = mock() private val settingsStore = mock() - private val hwWalletRepo = mock() private val transferRepo = mock() + private val hardwareWalletId = ActivityWalletType.TREZOR.idPrefixed("dev1") companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -48,7 +51,6 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.wallet__activity_error_load_failed)).thenReturn("Failed to load activity") whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) runBlocking { whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null)) } @@ -59,15 +61,14 @@ class ActivityDetailViewModelTest : BaseUnitTest() { activityRepo = activityRepo, blocktankRepo = blocktankRepo, settingsStore = settingsStore, - hwWalletRepo = hwWalletRepo, transferRepo = transferRepo, ) } @Test - fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { + fun `loadActivity resolves a hardware wallet activity and tags it via its wallet id`() = test { val hwActivity = Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + OnchainActivity.create( id = ACTIVITY_ID, txType = PaymentType.RECEIVED, txId = ACTIVITY_ID, @@ -76,51 +77,39 @@ class ActivityDetailViewModelTest : BaseUnitTest() { address = "", timestamp = 1_700_000_000uL, confirmed = true, + walletId = hardwareWalletId, ) ) - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf(hwActivity))) - - sut.loadActivity(ACTIVITY_ID) - - val state = sut.uiState.value - val loadState = state.activityLoadState as ActivityDetailViewModel.ActivityLoadState.Success - assertEquals(hwActivity, loadState.activity) - assertTrue(state.isHardwareActivity) - } - - @Test - fun `hardware wallet activity updates while loaded`() = test { - val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) - val updatedActivity = createTestActivity(ACTIVITY_ID, confirmed = true) - val hardwareActivities = MutableStateFlow(persistentListOf(initialActivity)) - - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(null)) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) - - sut.loadActivity(ACTIVITY_ID) - - val initialState = sut.uiState.value.activityLoadState - assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(initialActivity, initialState.activity) - - hardwareActivities.value = persistentListOf(updatedActivity) - - val updatedState = sut.uiState.value.activityLoadState - assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success) - assertEquals(updatedActivity, updatedState.activity) - assertTrue(sut.uiState.value.isHardwareActivity) + whenever { activityRepo.getActivity(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(hwActivity)) + whenever { activityRepo.getActivityTags(ACTIVITY_ID, hardwareWalletId) }.thenReturn(Result.success(emptyList())) + whenever { + activityRepo.addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + }.thenReturn(Result.success(Unit)) + whenever { settingsStore.addLastUsedTag("tag1") }.thenReturn(Unit) + + sut.loadActivity(ACTIVITY_ID, hardwareWalletId) + val loadState = sut.uiState.value.activityLoadState + assertTrue(loadState is ActivityDetailViewModel.ActivityLoadState.Success) + val activity = loadState.activity + assertTrue(activity is Activity.Onchain) + assertEquals(ACTIVITY_ID, activity.v1.id) + assertEquals(hardwareWalletId, activity.v1.walletId) + + sut.addTag("tag1") + + verify(activityRepo, atLeastOnce()).getActivity(ACTIVITY_ID, hardwareWalletId) + verify(activityRepo).addTagsToActivity(ACTIVITY_ID, listOf("tag1"), hardwareWalletId) + verify(activityRepo, atLeastOnce()).getActivityTags(ACTIVITY_ID, hardwareWalletId) } @Test - fun `loadActivity reports not found when missing from database and hardware wallets`() = test { - whenever { activityRepo.getActivity(ACTIVITY_ID) }.thenReturn(Result.success(null)) + fun `loadActivity reports not found when missing from the database`() = test { + whenever { activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull()) }.thenReturn(Result.success(null)) sut.loadActivity(ACTIVITY_ID) val state = sut.uiState.value assertTrue(state.activityLoadState is ActivityDetailViewModel.ActivityLoadState.Error) - assertFalse(state.isHardwareActivity) } @Test @@ -181,8 +170,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(initialActivity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(initialActivity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -193,7 +182,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertEquals(initialActivity, initialState.activity) // Simulate activity update - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(updatedActivity)) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(updatedActivity)) activitiesChangedFlow.value += 1 // Verify ViewModel reflects updated activity @@ -208,8 +197,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) @@ -232,14 +221,15 @@ class ActivityDetailViewModelTest : BaseUnitTest() { val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis()) whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow) - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(activity)) - whenever(activityRepo.getActivityTags(ACTIVITY_ID)).thenReturn(Result.success(emptyList())) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(activity)) + whenever(activityRepo.getActivityTags(eq(ACTIVITY_ID), anyOrNull())).thenReturn(Result.success(emptyList())) // Load activity sut.loadActivity(ACTIVITY_ID) // Simulate reload failure - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Network error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Network error"))) activitiesChangedFlow.value += 1 // Verify last known state is preserved @@ -250,7 +240,8 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `loadActivity handles error gracefully`() = test { - whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Database error"))) + whenever(activityRepo.getActivity(eq(ACTIVITY_ID), anyOrNull())) + .thenReturn(Result.failure(Exception("Database error"))) sut.loadActivity(ACTIVITY_ID) @@ -263,7 +254,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { confirmed: Boolean = false, ): Activity.Onchain { return Activity.Onchain( - v1 = OnchainActivity.create(walletId = "wallet0", + v1 = OnchainActivity.create( id = id, txType = PaymentType.RECEIVED, txId = "tx-$id", diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index e811196278..ef9fad51f7 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity @@ -15,8 +16,8 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat -import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -26,17 +27,21 @@ import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity +import to.bitkit.ext.DEFAULT_WALLET_ID import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.ext.mock +import to.bitkit.models.ActivityWalletType import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @Suppress("LargeClass") @OptIn(ExperimentalTime::class) @@ -62,8 +67,9 @@ class ActivityRepoTest : BaseUnitTest() { private val testActivity = mock { on { v1 } doReturn testActivityV1 } + private val hardwareWalletId = ActivityWalletType.TREZOR.idPrefixed("dev1") - private val baseOnchainActivity = OnchainActivity.create(walletId = "wallet0", + private val baseOnchainActivity = OnchainActivity.create( id = "base_activity_id", txType = PaymentType.SENT, txId = "base_tx_id", @@ -77,6 +83,7 @@ class ActivityRepoTest : BaseUnitTest() { @Suppress("LongParameterList") private fun createOnchainActivity( id: String = baseOnchainActivity.id, + txType: PaymentType = baseOnchainActivity.txType, txId: String = baseOnchainActivity.txId, value: ULong = baseOnchainActivity.value, fee: ULong = baseOnchainActivity.fee, @@ -94,10 +101,12 @@ class ActivityRepoTest : BaseUnitTest() { contact: String? = baseOnchainActivity.contact, createdAt: ULong? = baseOnchainActivity.createdAt, updatedAt: ULong? = baseOnchainActivity.updatedAt, + walletId: String = baseOnchainActivity.walletId, ): Activity.Onchain { return Activity.Onchain( v1 = baseOnchainActivity.copy( id = id, + txType = txType, txId = txId, value = value, fee = fee, @@ -114,11 +123,20 @@ class ActivityRepoTest : BaseUnitTest() { transferTxId = transferTxId, contact = contact, createdAt = createdAt, - updatedAt = updatedAt + updatedAt = updatedAt, + walletId = walletId, ) ) } + private fun transactionDetails(txId: String, amountSats: Long) = BitkitCoreTransactionDetails( + walletId = hardwareWalletId, + txId = txId, + amountSats = amountSats, + inputs = emptyList(), + outputs = emptyList(), + ) + @Before fun setUp() { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) @@ -159,7 +177,7 @@ class ActivityRepoTest : BaseUnitTest() { fun `syncActivities success flow`() = test { val payments = listOf(testPaymentDetails) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(payments)) - wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) + whenever(coreService.activity.getActivity(any(), anyOrNull())).thenReturn(null) wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities( any>(), @@ -196,6 +214,7 @@ class ActivityRepoTest : BaseUnitTest() { wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = any(), txType = any(), tags = any(), @@ -225,6 +244,7 @@ class ActivityRepoTest : BaseUnitTest() { val activities = listOf(testActivity) wheneverBlocking { coreService.activity.get( + walletId = ActivityWalletType.BITKIT.id(), filter = ActivityFilter.ALL, txType = PaymentType.RECEIVED, tags = listOf("tag1"), @@ -251,10 +271,30 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(activities, result.getOrThrow()) } + @Test + fun `getAllActivitiesTags defaults to Bitkit wallet`() = test { + val bitkitTags = ActivityTags( + walletId = ActivityWalletType.BITKIT.id(), + activityId = "bitkit-activity", + tags = listOf("bitkit"), + ) + val hardwareTags = ActivityTags( + walletId = hardwareWalletId, + activityId = "hardware-activity", + tags = listOf("hardware"), + ) + whenever(coreService.activity.getAllActivitiesTags()) + .thenReturn(listOf(bitkitTags, hardwareTags)) + + val result = sut.getAllActivitiesTags() + + assertEquals(listOf(bitkitTags), result.getOrThrow()) + } + @Test fun `getActivity returns activity when found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) + whenever(coreService.activity.getActivity(activityId, null)).thenReturn(testActivity) val result = sut.getActivity(activityId) @@ -263,85 +303,126 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { - val existing = createOnchainActivity( - id = "transfer-txid", - txId = "transfer-txid", - value = 50_000uL, - fee = 0uL, - feeRate = 2uL, - address = "bc1qlsp", - confirmed = false, - timestamp = 1_000uL, - isTransfer = true, - channelId = "channel-1", - isBoosted = true, - boostTxIds = listOf("boost-txid"), - contact = "contact", - ).v1 - val watcher = OnchainActivity.create(walletId = "wallet0", - id = "transfer-txid", - txType = PaymentType.SENT, - txId = "transfer-txid", - value = 49_000uL, - fee = 1_250uL, - address = "", - timestamp = 2_000uL, - confirmed = true, + fun `getActivity passes wallet id to core lookup`() = test { + val activityId = "activity123" + whenever(coreService.activity.getActivity(activityId, hardwareWalletId)).thenReturn(testActivity) + + val result = sut.getActivity(activityId, hardwareWalletId) + + assertTrue(result.isSuccess) + assertEquals(testActivity, result.getOrThrow()) + verify(coreService.activity).getActivity(activityId, hardwareWalletId) + } + + @Test + fun `persistHardware upserts multiple activities and transaction details`() = test { + val activities = listOf( + createOnchainActivity( + id = "hw-received-id", + txId = "hw-received-txid", + value = 10_000uL, + fee = 0uL, + timestamp = 2_000uL, + confirmed = true, + walletId = hardwareWalletId, + ), + createOnchainActivity( + id = "hw-sent-id", + txId = "hw-sent-txid", + value = 4_200uL, + fee = 321uL, + timestamp = 3_000uL, + confirmed = false, + walletId = hardwareWalletId, + txType = PaymentType.SENT, + ), + createOnchainActivity( + id = "hw-transfer-id", + txId = "hw-transfer-txid", + value = 7_000uL, + fee = 222uL, + timestamp = 4_000uL, + confirmed = true, + isTransfer = true, + walletId = hardwareWalletId, + txType = PaymentType.SENT, + ), ) - whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + val details = listOf( + transactionDetails("hw-received-txid", 10_000L), + transactionDetails("hw-sent-txid", -4_521L), + transactionDetails("hw-transfer-txid", -7_222L), + ) + whenever(coreService.activity.replaceHardwareSnapshot(hardwareWalletId, activities, details)) + .thenReturn(activities) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardware(hardwareWalletId, activities, details) assertTrue(result.isSuccess) - val captor = argumentCaptor() - verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) - val updated = (captor.firstValue as Activity.Onchain).v1 - assertTrue(updated.confirmed) - assertEquals(2_000uL, updated.confirmTimestamp) - assertEquals(true, updated.doesExist) - assertEquals(50_000uL, updated.value) - assertEquals(1_250uL, updated.fee) - assertEquals(2uL, updated.feeRate) - assertEquals("bc1qlsp", updated.address) - assertEquals(true, updated.isTransfer) - assertEquals("channel-1", updated.channelId) - assertEquals(true, updated.isBoosted) - assertEquals(listOf("boost-txid"), updated.boostTxIds) - assertEquals("contact", updated.contact) - } - - @Test - fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create(walletId = "wallet0", - id = "hardware-only-txid", - txType = PaymentType.RECEIVED, - txId = "hardware-only-txid", - value = 10_000uL, - fee = 0uL, - address = "", - timestamp = 2_000uL, + assertEquals(activities, result.getOrThrow()) + verify(coreService.activity).replaceHardwareSnapshot(hardwareWalletId, activities, details) + } + + @Test + fun `persistHardware returns canonical transfer metadata from core`() = test { + val incoming = createOnchainActivity( + id = "hw-transfer-txid", + txId = "hw-transfer-txid", + value = 7_000uL, + fee = 222uL, + timestamp = 4_000uL, confirmed = true, + isTransfer = false, + walletId = hardwareWalletId, + txType = PaymentType.SENT, ) - whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + val expected = Activity.Onchain( + incoming.v1.copy( + isTransfer = true, + channelId = "channel-id", + ) + ) + whenever { + coreService.activity.replaceHardwareSnapshot(hardwareWalletId, listOf(incoming), emptyList()) + }.thenReturn(listOf(expected)) - val result = sut.syncHardwareOnchainActivity(watcher) + val result = sut.persistHardware(hardwareWalletId, listOf(incoming), emptyList()) assertTrue(result.isSuccess) - verify(coreService.activity, never()).update(any(), any()) - verify(coreService.activity, never()).insert(any()) - verify(coreService.activity, never()).upsert(any()) + assertEquals(listOf(expected), result.getOrThrow()) + } + + @Test + fun `persistHardware reconciles an empty snapshot`() = test { + whenever { coreService.activity.replaceHardwareSnapshot(hardwareWalletId, emptyList(), emptyList()) } + .thenReturn(emptyList()) + + val result = sut.persistHardware(hardwareWalletId, emptyList(), emptyList()) + + assertTrue(result.isSuccess) + verify(coreService.activity).replaceHardwareSnapshot(hardwareWalletId, emptyList(), emptyList()) + } + + @Test + fun `deleteForWallet delegates to core delete by wallet id`() = test { + whenever(coreService.activity.deleteByWalletId(hardwareWalletId)).thenReturn(3u) + + val result = sut.deleteForWallet(hardwareWalletId) + + assertTrue(result.isSuccess) + verify(coreService.activity).deleteByWalletId(hardwareWalletId) } @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + whenever(coreService.activity.getActivity(activityId, null)).thenReturn(null) val result = sut.getActivity(activityId) assertTrue(result.isSuccess) assertNull(result.getOrThrow()) + verify(coreService.activity, never()).get(walletId = null) } @Test @@ -360,9 +441,10 @@ class ActivityRepoTest : BaseUnitTest() { boostTxIds = listOf(replacedTxId), contact = contactPublicKey, ) - whenever(coreService.activity.getTxIdsInBoostTxIds()).thenReturn(setOf(replacedTxId)) + whenever(coreService.activity.getTxIdsInBoostTxIds(DEFAULT_WALLET_ID)).thenReturn(setOf(replacedTxId)) whenever( coreService.activity.get( + walletId = ActivityWalletType.BITKIT.id(), filter = ActivityFilter.ALL, txType = null, tags = null, @@ -397,6 +479,7 @@ class ActivityRepoTest : BaseUnitTest() { whenever(coreService.activity.getOnchainActivityByTxId(replacedTxId)).thenReturn(replacedActivity.v1) whenever( coreService.activity.get( + walletId = ActivityWalletType.BITKIT.id(), filter = ActivityFilter.ONCHAIN, txType = null, tags = null, @@ -477,8 +560,6 @@ class ActivityRepoTest : BaseUnitTest() { // Mock update for the new activity wheneverBlocking { coreService.activity.update(activityId, testActivity) }.thenReturn(Unit) - // Mock getActivity to return the new activity (for addTagsToActivity check) - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) // Mock tags retrieval from the old activity wheneverBlocking { coreService.activity.tags(activityToDeleteId) }.thenReturn(tagsMock) // Mock tags retrieval from the new activity (should be empty so all tags are considered new) @@ -496,7 +577,7 @@ class ActivityRepoTest : BaseUnitTest() { // Verify tags are added to the new activity verify(coreService.activity).appendTags(activityId, tagsMock) // Verify delete is NOT called - verify(coreService.activity, never()).delete(any()) + verify(coreService.activity, never()).delete(any(), anyOrNull()) // Verify addActivityToDeletedList is NOT called verify(cacheStore, never()).addActivityToDeletedList(any()) } @@ -592,7 +673,6 @@ class ActivityRepoTest : BaseUnitTest() { val newTags = listOf("tag2", "tag3", "tag4", "") // tag2 exists, empty string should be filtered val expectedNewTags = listOf("tag3", "tag4") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) wheneverBlocking { coreService.activity.appendTags( @@ -608,13 +688,14 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `addTagsToActivity fails when activity not found`() = test { + fun `addTagsToActivity fails when tags lookup fails`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + whenever(coreService.activity.tags(activityId)) doThrow AppError("tags failed") val result = sut.addTagsToActivity(activityId, listOf("tag1")) assertTrue(result.isFailure) + verify(coreService.activity, never()).appendTags(any(), any(), anyOrNull()) } @Test @@ -623,13 +704,12 @@ class ActivityRepoTest : BaseUnitTest() { val existingTags = listOf("tag1", "tag2") val duplicateTags = listOf("tag1", "tag2", "") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.tags(activityId) }.thenReturn(existingTags) val result = sut.addTagsToActivity(activityId, duplicateTags) assertTrue(result.isSuccess) - verify(coreService.activity, never()).appendTags(any(), any()) + verify(coreService.activity, never()).appendTags(any(), any(), anyOrNull()) } @Test @@ -661,7 +741,6 @@ class ActivityRepoTest : BaseUnitTest() { val activityId = "activity123" val tagsToRemove = listOf("tag1", "tag2") - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(testActivity) wheneverBlocking { coreService.activity.dropTags(activityId, tagsToRemove) }.thenReturn(Unit) val result = sut.removeTagsFromActivity(activityId, tagsToRemove) @@ -671,11 +750,12 @@ class ActivityRepoTest : BaseUnitTest() { } @Test - fun `removeTagsFromActivity fails when activity not found`() = test { + fun `removeTagsFromActivity fails when dropTags fails`() = test { val activityId = "activity123" - wheneverBlocking { coreService.activity.getActivity(activityId) }.thenReturn(null) + val tags = listOf("tag1") + whenever(coreService.activity.dropTags(activityId, tags)) doThrow AppError("drop failed") - val result = sut.removeTagsFromActivity(activityId, listOf("tag1")) + val result = sut.removeTagsFromActivity(activityId, tags) assertTrue(result.isFailure) } @@ -771,6 +851,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -823,6 +904,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -875,6 +957,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -926,6 +1009,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), @@ -978,6 +1062,7 @@ class ActivityRepoTest : BaseUnitTest() { setupSyncActivitiesMocks(cacheData) wheneverBlocking { coreService.activity.get( + walletId = anyOrNull(), filter = eq(ActivityFilter.ONCHAIN), txType = eq(PaymentType.SENT), tags = anyOrNull(), diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 29294a0e3b..1311c09647 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -5,22 +5,25 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TransactionDetails import com.synonym.bitkitcore.TrezorException +import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -33,23 +36,21 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.create +import to.bitkit.models.ActivityWalletType import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LargeClass") class HwWalletRepoTest : BaseUnitTest() { @@ -57,12 +58,12 @@ class HwWalletRepoTest : BaseUnitTest() { private val activityRepo = mock() private val hwWalletStore = mock() private val settingsStore = mock() - private val clock = mock() private lateinit var storeData: MutableStateFlow private lateinit var settingsData: MutableStateFlow private lateinit var trezorState: MutableStateFlow private lateinit var watcherEvents: MutableSharedFlow> + private val trezorWalletId = ActivityWalletType.TREZOR.idPrefixed("dev1") private val device = KnownDevice( id = "dev1", @@ -73,6 +74,7 @@ class HwWalletRepoTest : BaseUnitTest() { model = "Safe 5", lastConnectedAt = 0L, xpubs = mapOf("nativeSegwit" to "zpubNS"), + walletId = trezorWalletId, ) @Before @@ -86,13 +88,13 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) whenever(trezorRepo.deriveWalletId(any())).thenAnswer { invocation -> - val xpubs = invocation.getArgument>(0) - "derived-${xpubs.values.sorted().joinToString()}" + val xpubs = invocation.getArgument>(0) + derivedWalletIdFor(*xpubs.toTypedArray()) } - runBlocking { - whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) + whenever { activityRepo.persistHardware(any(), any(), any()) }.thenAnswer { + it.getArgument>(1) } - whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) + whenever { activityRepo.deleteForWallet(any()) }.thenReturn(Result.success(Unit)) } private fun createRepo() = HwWalletRepo( @@ -100,7 +102,6 @@ class HwWalletRepoTest : BaseUnitTest() { activityRepo = activityRepo, hwWalletStore = hwWalletStore, settingsStore = settingsStore, - clock = clock, ioDispatcher = testDispatcher, ) @@ -145,15 +146,30 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `transactions changed event sets device balance and maps activity`() = test { + fun `transactions changed event sets balance, exposes activities and persists matching wallet entries`() = test { val sut = createRepo() + val received = watcherActivity(amount = 10_562_411uL, txid = "t1") + val sent = watcherActivity(amount = 125_000uL, txid = "t2", txType = PaymentType.SENT) + val otherWalletId = ActivityWalletType.TREZOR.idPrefixed("dev2") + val otherWallet = Activity.Onchain( + watcherActivity(amount = 7_000uL, txid = "other-wallet-tx").v1.copy(walletId = otherWalletId) + ) + val details = listOf( + transactionDetails(txid = "t1", amountSats = 10_562_411L), + transactionDetails(txid = "t2", amountSats = -125_000L), + ) + val otherWalletDetails = transactionDetails( + txid = "other-wallet-tx", + amountSats = 7_000L, + walletId = otherWalletId, + ) watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 10_562_411uL), - activities = listOf(watcherActivity(amount = 10_562_411uL)), - transactionDetails = emptyList(), - txCount = 1u, + activities = listOf(received, sent, otherWallet), + transactionDetails = details + otherWalletDetails, + txCount = 3u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, ) @@ -162,14 +178,77 @@ class HwWalletRepoTest : BaseUnitTest() { val wallet = sut.wallets.value.single() assertEquals(10_562_411uL, wallet.balanceSats) assertEquals(10_562_411uL, sut.totalSats.value) - assertEquals(1, wallet.activities.size) - assertEquals(1, sut.activities.value.size) - assertEquals(Activity.Onchain::class, wallet.activities.single()::class) - verify(activityRepo).syncHardwareOnchainActivity((wallet.activities.single() as Activity.Onchain).v1) + assertEquals(listOf("t1", "t2"), wallet.activities.map { (it as Activity.Onchain).v1.txId }) + verify(activityRepo).persistHardware(trezorWalletId, listOf(received, sent), details) + } + + @Test + fun `transactions changed event from inactive watcher is ignored`() = test { + val sut = createRepo() + val activity = watcherActivity(txid = "t1", amount = 10_562_411uL) + + watcherEvents.emit( + "random|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 10_562_411uL), + activities = listOf(activity), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(0uL, sut.totalSats.value) + verify(activityRepo, never()).persistHardware(any(), any(), any()) + } + + @Test + fun `transactions changed event updates balance without exposing unpersisted activity`() = test { + val activity = watcherActivity(txid = "t1", amount = 10_562_411uL) + whenever { activityRepo.persistHardware(trezorWalletId, listOf(activity), emptyList()) } + .thenReturn(Result.failure(AppError("persist failed"))) + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 10_562_411uL), + activities = listOf(activity), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(10_562_411uL, sut.totalSats.value) + assertTrue(sut.wallets.value.single().activities.isEmpty()) + } + + @Test + fun `transactions changed event exposes canonical persisted activities`() = test { + val activity = watcherActivity(txid = "t1", amount = 10_562_411uL, txType = PaymentType.SENT) + val canonical = Activity.Onchain(activity.v1.copy(isTransfer = true, channelId = "channel-id")) + val pendingTransfer = watcherActivity(txid = "t2", amount = 5_000uL, txType = PaymentType.SENT) + whenever { activityRepo.persistHardware(trezorWalletId, listOf(activity), emptyList()) } + .thenReturn(Result.success(listOf(canonical, pendingTransfer))) + val sut = createRepo() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 10_562_411uL), + activities = listOf(activity), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(listOf(canonical, pendingTransfer), sut.wallets.value.single().activities) } @Test - fun `balances from multiple address-type watchers are summed per device`() = test { + fun `inactive address-type watcher event is ignored`() = test { val sut = createRepo() watcherEvents.emit( @@ -192,9 +271,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) val wallet = sut.wallets.value.single() - assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.balanceSats) assertEquals(100uL, wallet.fundingBalanceSats) - assertEquals(150uL, sut.totalSats.value) + assertEquals(100uL, sut.totalSats.value) } @Test @@ -222,7 +301,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `merges duplicate tx activities from multiple address-type watchers`() = test { + fun `duplicate tx from inactive address-type watcher is ignored`() = test { val sut = createRepo() watcherEvents.emit( @@ -248,17 +327,18 @@ class HwWalletRepoTest : BaseUnitTest() { val activity = sut.wallets.value.single().activities.single() as Activity.Onchain assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) - assertEquals(150uL, sut.wallets.value.single().balanceSats) + assertEquals(100uL, activity.v1.value) + assertEquals(100uL, sut.wallets.value.single().balanceSats) } @Test - fun `merges duplicate tx activities across hardware wallets`() = test { + fun `keeps duplicate tx activities scoped by hardware wallet`() = test { val secondDevice = device.copy( id = "dev2", path = "ble:CC:DD", lastConnectedAt = 1L, xpubs = mapOf("nativeSegwit" to "zpubNS2"), + walletId = ActivityWalletType.TREZOR.idPrefixed("dev2"), ) storeData.value = HwWalletData(knownDevices = listOf(device, secondDevice)) wheneverStartWatcher().thenReturn(Result.success(Unit)) @@ -277,7 +357,13 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + activities = listOf( + watcherActivity( + amount = 50uL, + txid = "shared", + walletId = ActivityWalletType.TREZOR.idPrefixed("dev2"), + ) + ), transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, @@ -285,10 +371,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - val activity = sut.activities.value.single() as Activity.Onchain assertEquals(2, sut.wallets.value.size) - assertEquals(PaymentType.RECEIVED, activity.v1.txType) - assertEquals(150uL, activity.v1.value) + assertEquals(2, sut.activities.value.size) + assertEquals(setOf(100uL, 50uL), sut.activities.value.map { (it as Activity.Onchain).v1.value }.toSet()) } @Test @@ -323,7 +408,7 @@ class HwWalletRepoTest : BaseUnitTest() { val activity = sut.wallets.value.single().activities.single() as Activity.Onchain assertEquals(PaymentType.SENT, activity.v1.txType) - assertEquals(60_000uL, activity.v1.value) + assertEquals(40_000uL, activity.v1.value) assertEquals(fee, activity.v1.fee) } @@ -335,7 +420,6 @@ class HwWalletRepoTest : BaseUnitTest() { txid = "pending", blockHeight = null, timestamp = 1_800_000_000uL, - confirmations = 0u, ) watcherEvents.emit( @@ -436,7 +520,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `restarts active watchers when wallet id changes`() = test { - val derivedWalletId = "derived-zpubNS" + val derivedWalletId = derivedWalletIdFor("zpubNS") storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = "legacy-wallet-id"))) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) @@ -485,22 +569,67 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = any(), - walletId = eq("derived-zpubNS"), + walletId = eq(derivedWalletIdFor("zpubNS")), ) } + @Test + fun `captures initial watcher event emitted before start returns`() = test { + val activity = watcherActivity(amount = 42_000uL) + val details = transactionDetails(txid = "t1", amountSats = 42_000L) + wheneverStartWatcher().doSuspendableAnswer { + watcherEvents.tryEmit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 42_000uL), + activities = listOf(activity), + transactionDetails = listOf(details), + txCount = 1u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + Result.success(Unit) + } + + val sut = createRepo() + runCurrent() + + assertEquals(42_000uL, sut.totalSats.value) + assertEquals(activity, sut.wallets.value.single().activities.single()) + verify(activityRepo).persistHardware(trezorWalletId, listOf(activity), listOf(details)) + } + @Test fun `retries watcher start after failure`() = test { wheneverStartWatcher().thenReturn(Result.failure(AppError("start failed")), Result.success(Unit)) - createRepo() + val sut = createRepo() verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 1uL), + activities = emptyList(), + transactionDetails = emptyList(), + txCount = 0u, + blockHeight = 850_000u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertEquals(0uL, sut.totalSats.value) advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, times(2)).startWatcher( + eq("dev1|nativeSegwit"), + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + ) } @Test @@ -536,7 +665,7 @@ class HwWalletRepoTest : BaseUnitTest() { accountType = AccountType.NATIVE_SEGWIT, ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL, walletId = trezorWalletId)), received) // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( @@ -558,7 +687,58 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `emits received tx once when multiple watchers report the same new tx`() = test { + fun `does not emit restored history when initial persistence recovers`() = test { + val first = watcherActivity(amount = 100uL) + val second = watcherActivity(amount = 50uL, txid = "t2") + whenever { activityRepo.persistHardware(eq(trezorWalletId), any(), any()) } + .thenReturn( + Result.failure(AppError("persist failed")), + Result.success(listOf(first)), + Result.success(listOf(first, second)), + ) + val sut = createRepo() + val received = mutableListOf() + val job = launch { sut.receivedTxs.collect { received += it } } + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + activities = listOf(first), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + activities = listOf(first), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 2u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + assertTrue(received.isEmpty()) + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 150uL), + activities = listOf(first, second), + transactionDetails = emptyList(), + txCount = 2u, + blockHeight = 3u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + + assertEquals(listOf(HwWalletReceivedTx(txid = "t2", sats = 50uL, walletId = trezorWalletId)), received) + job.cancel() + } + + @Test + fun `ignores duplicate received tx from inactive watcher`() = test { val sut = createRepo() val received = mutableListOf() val job = launch { sut.receivedTxs.collect { received += it } } @@ -603,7 +783,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL)), received) + assertEquals(listOf(HwWalletReceivedTx(txid = "shared", sats = 100uL, walletId = trezorWalletId)), received) job.cancel() } @@ -649,7 +829,15 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, never()).startWatcher( + eq("usb1|nativeSegwit"), + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + ) watcherEvents.emit( "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -713,14 +901,12 @@ class HwWalletRepoTest : BaseUnitTest() { ) runCurrent() - // Dropping the native-segwit xpub stops the watcher; a failed stop keeps ghost balance visible. storeData.value = HwWalletData( knownDevices = listOf(device.copy(xpubs = mapOf("taproot" to "zpubTR"))), ) runCurrent() assertEquals(100uL, sut.totalSats.value) - // Stop succeeds on a later sync: the watcher data is finally dropped. whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) storeData.value = HwWalletData( knownDevices = listOf( @@ -771,6 +957,119 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) verify(trezorRepo).stopWatcher("dev1|nativeSegwit") verify(trezorRepo).forgetDevice("dev1") + verify(activityRepo).deleteForWallet(trezorWalletId) + } + + @Test + fun `removeDevice waits for in-flight persistence before purging`() = test { + val activity = watcherActivity(txid = "t1", amount = 100uL) + val persistStarted = CompletableDeferred() + val allowPersist = CompletableDeferred() + val calls = mutableListOf() + wheneverStartWatcher().thenReturn(Result.success(Unit)) + whenever { activityRepo.persistHardware(trezorWalletId, listOf(activity), emptyList()) } + .doSuspendableAnswer { + persistStarted.complete(Unit) + allowPersist.await() + calls += "persisted" + Result.success(listOf(activity)) + } + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteForWallet(trezorWalletId) }.doSuspendableAnswer { + calls += "purged" + Result.success(Unit) + } + val sut = createRepo() + runCurrent() + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 100uL), + activities = listOf(activity), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + persistStarted.await() + val removal = async { sut.removeDevice("dev1") } + runCurrent() + assertTrue(calls.isEmpty()) + + allowPersist.complete(Unit) + + assertTrue(removal.await().isSuccess) + assertEquals(listOf("persisted", "purged"), calls) + } + + @Test + fun `removeDevice waits for watcher start before stopping and purging`() = test { + val startEntered = CompletableDeferred() + val allowStart = CompletableDeferred() + val calls = mutableListOf() + wheneverStartWatcher().doSuspendableAnswer { + startEntered.complete(Unit) + allowStart.await() + calls += "started" + Result.success(Unit) + } + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.doSuspendableAnswer { + calls += "stopped" + Result.success(Unit) + } + whenever { activityRepo.deleteForWallet(trezorWalletId) }.doSuspendableAnswer { + calls += "purged" + Result.success(Unit) + } + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + val sut = createRepo() + startEntered.await() + + val removal = async { sut.removeDevice("dev1") } + runCurrent() + assertTrue(calls.isEmpty()) + + allowStart.complete(Unit) + + assertTrue(removal.await().isSuccess) + assertEquals(listOf("started", "stopped", "purged"), calls) + } + + @Test + fun `removeDevice fails when activity purge fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.deleteForWallet(trezorWalletId) } + .thenReturn(Result.failure(AppError("purge failed"))) + val sut = createRepo() + runCurrent() + + val result = sut.removeDevice("dev1") + + assertEquals(true, result.isFailure) + verify(activityRepo).deleteForWallet(trezorWalletId) + verify(trezorRepo, never()).forgetDevice(any()) + } + + @Test + fun `removeDevice purges derived wallet id for legacy device`() = test { + val legacyDevice = device.copy(walletId = "") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(legacyDevice), emptyList()) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + val result = sut.removeDevice("dev1") + + assertEquals(true, result.isSuccess) + verify(activityRepo).deleteForWallet(derivedWalletIdFor("zpubNS")) + verify(trezorRepo).forgetDevice("dev1") } @Test @@ -1053,17 +1352,18 @@ class HwWalletRepoTest : BaseUnitTest() { total = total, ) + @Suppress("LongParameterList") private fun watcherActivity( amount: ULong, txid: String = "t1", txType: PaymentType = PaymentType.RECEIVED, blockHeight: UInt? = 850_000u, timestamp: ULong? = 1_700_000_000uL, - confirmations: UInt = 3u, fee: ULong = 0uL, + walletId: String = trezorWalletId, ) = Activity.Onchain( OnchainActivity.create( - walletId = "wallet0", + walletId = walletId, id = txid, txType = txType, txId = txid, @@ -1071,10 +1371,24 @@ class HwWalletRepoTest : BaseUnitTest() { fee = fee, address = "", timestamp = timestamp ?: 0uL, - confirmed = blockHeight != null && confirmations > 0u, + confirmed = blockHeight != null, ) ) + private fun transactionDetails( + txid: String, + amountSats: Long, + walletId: String = trezorWalletId, + ) = TransactionDetails( + walletId = walletId, + txId = txid, + amountSats = amountSats, + inputs = emptyList(), + outputs = emptyList(), + ) + + private fun derivedWalletIdFor(vararg xpubs: String): String = + ActivityWalletType.TREZOR.idPrefixed("derived-${xpubs.sorted().joinToString()}") @Test fun `scan delegates to trezorRepo`() = test { diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index d1c8409d49..a8a8eff9bc 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking +import to.bitkit.models.ActivityWalletType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -36,6 +37,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { private var timestampCounter = 0L private val testMetadata = PreActivityMetadata( + walletId = ActivityWalletType.BITKIT.id(), paymentId = "payment-123", createdAt = 1234567890uL, tags = listOf("tag1", "tag2"), @@ -45,8 +47,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { isReceive = false, feeRate = 10u, isTransfer = false, - channelId = "channel-123", - walletId = "wallet0", + channelId = "channel-123" ) @Before @@ -376,8 +377,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isSuccess) @@ -395,8 +395,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = false, tags = emptyList(), - isTransfer = true, - walletId = "wallet0", + isTransfer = true ) assertTrue(result.isSuccess) @@ -416,8 +415,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { tags = listOf("important"), feeRate = 10u, isTransfer = true, - channelId = "channel-123", - walletId = "wallet0", + channelId = "channel-123" ) assertTrue(result.isSuccess) @@ -434,8 +432,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = emptyList(), - isTransfer = false, - walletId = "wallet0", + isTransfer = false ) assertTrue(result.isFailure) @@ -460,8 +457,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - feeRate = null, - walletId = "wallet0", + feeRate = null ) assertTrue(result.isSuccess) @@ -484,8 +480,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - channelId = null, - walletId = "wallet0", + channelId = null ) assertTrue(result.isSuccess) @@ -503,8 +498,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isFailure) @@ -600,8 +594,7 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags, - walletId = "wallet0", + tags = tags ) assertTrue(result.isSuccess) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index f69cf75225..50c9c1ca5b 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import app.cash.turbine.test import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtOrder @@ -28,10 +29,12 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails +import to.bitkit.models.ActivityWalletType import to.bitkit.models.TransferType import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -58,6 +61,7 @@ class TransferRepoTest : BaseUnitTest() { private const val ID_ORDER = "test-order-id" private const val ID_CHANNEL = "test-channel-id" private const val ID_TRANSFER = "test-transfer-id" + private val ID_HW_WALLET = ActivityWalletType.TREZOR.idPrefixed("dev1") private val fundingTxo = OutPoint(txid = "test-funding-tx-id", vout = 0u) } @@ -147,6 +151,33 @@ class TransferRepoTest : BaseUnitTest() { verify(transferDao).insert(any()) } + @Test + fun `createPendingToSpendingActivity passes hardware wallet id`() = test { + val order = previewBtOrder() + val fee = 123uL + val feeRate = 2uL + + val result = sut.createPendingToSpendingActivity( + order = order, + txId = fundingTxo.txid, + fee = fee, + feeRate = feeRate, + walletId = ID_HW_WALLET, + ) + + assertTrue(result.isSuccess) + verify(activityService).createSentOnchainActivityFromSendResult( + eq(fundingTxo.txid), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(fee), + eq(feeRate), + eq(true), + eq(order.channel?.shortChannelId), + eq(ID_HW_WALLET), + ) + } + @Test fun `createTransfer handles database insertion failure`() = test { setupClockNowMock() @@ -275,7 +306,8 @@ class TransferRepoTest : BaseUnitTest() { } @Test - fun `syncTransferStates persists resolved channel and marks activity for TO_SPENDING transfer`() = test { + @Suppress("LongMethod") + fun `syncTransferStates marks watcher-first hardware sent as transfer`() = test { val transfer = TransferEntity( id = ID_TRANSFER, type = TransferType.TO_SPENDING, @@ -291,7 +323,7 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create(walletId = "wallet0", + val activity = OnchainActivity.create( id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -299,15 +331,39 @@ class TransferRepoTest : BaseUnitTest() { fee = 0uL, address = "bc1qtest", timestamp = 1000uL, - isTransfer = true, + isTransfer = false, channelId = null, + walletId = ID_HW_WALLET, + ) + val unrelated = OnchainActivity.create( + id = "unrelated-hardware-activity", + txType = PaymentType.RECEIVED, + txId = fundingTxo.txid, + value = 50_000uL, + fee = 0uL, + address = "bc1qunrelated", + timestamp = 1000uL, + isTransfer = false, + walletId = ActivityWalletType.TREZOR.idPrefixed("dev2"), ) whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) whenever(lightningRepo.getChannels()).thenReturn(listOf(channelDetails)) whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(mock())) whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)).thenReturn(Result.success(null)) - whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid)).thenReturn(activity) + whenever( + activityService.get( + walletId = anyOrNull(), + filter = eq(ActivityFilter.ONCHAIN), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + ).thenReturn(listOf(Activity.Onchain(unrelated), Activity.Onchain(activity))) val result = sut.syncTransferStates() @@ -317,6 +373,7 @@ class TransferRepoTest : BaseUnitTest() { eq(activity.id), eq(Activity.Onchain(activity.copy(isTransfer = true, channelId = ID_CHANNEL))), ) + verify(activityService, never()).getOnchainActivityByTxId(fundingTxo.txid) } @Test @@ -609,7 +666,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = "sweep-txid", @@ -643,6 +700,7 @@ class TransferRepoTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), anyOrNull() ) ) @@ -741,7 +799,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create(walletId = "wallet0", + val sweepActivity = OnchainActivity.create( id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = sweepTxid, diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5b135450a4..57fabe54c0 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -39,6 +39,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.models.ActivityWalletType import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork @@ -70,6 +71,9 @@ class TrezorRepoTest : BaseUnitTest() { private const val TEST_SIGNATURE = "signature123" private const val TEST_ADDRESS = "bc1qtest" private const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." + private const val TEST_UPUB = + "upub5DWPhYKrgLiETEmgLykymFzBK6gXUNvkE67HLSEh5zcWNRdx2Hxd8HypxaDa" + + "K1p62a1kXwe9eBcV3pGm7yEpHh4ebrspSoer4x8Ko29egtv" } @get:Rule(order = 1) @@ -108,6 +112,9 @@ class TrezorRepoTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__connect_error)).thenReturn("Could not connect to your Trezor.") whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) + whenever(trezorService.deriveWalletId(any(), any())).thenAnswer { + walletIdFor(*it.getArgument>(1).toTypedArray()) + } stubAccountXpubFetch() } @@ -122,6 +129,9 @@ class TrezorRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, ) + private fun walletIdFor(vararg xpubs: String): String = + ActivityWalletType.TREZOR.idPrefixed(xpubs.sorted().joinToString("|")) + @Suppress("LongParameterList") private fun mockDeviceInfo( id: String = DEVICE_ID, @@ -229,6 +239,39 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals("", sut.state.value.knownDevices.single().walletId) } + @Test + fun `initialize derives wallet id for restored device`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = mapOf("nativeSegwit" to "xpubNS")) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + val savedCaptor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) + val saved = savedCaptor.firstValue.single() + assertEquals(walletIdFor("xpubNS"), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id(), setOf("xpubNS")) + } + + @Test + fun `initialize keeps restored slip132 xpub for core derivation`() = test { + val knownDevice = mockKnownDevice(walletId = "", xpubs = mapOf("nativeSegwit" to TEST_UPUB)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + val savedCaptor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) + val saved = savedCaptor.firstValue.single() + assertEquals(TEST_UPUB, saved.xpubs["nativeSegwit"]) + assertEquals(walletIdFor(TEST_UPUB), saved.walletId) + verify(trezorService).deriveWalletId(ActivityWalletType.TREZOR.id(), setOf(TEST_UPUB)) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -684,7 +727,8 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) - assertEquals("", saved.walletId) + assertEquals(walletIdFor(*saved.xpubs.values.toTypedArray()), saved.walletId) + verify(trezorService).deriveWalletId(eq(ActivityWalletType.TREZOR.id()), any()) } @Test @@ -728,6 +772,44 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } + @Test + fun `connect derives new wallet id when xpub identity changes`() = test { + val oldWalletId = walletIdFor("old-native-xpub") + val nativeSegwitPath = "m/84'/1'/0'" + val previousDevice = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "old-native-xpub"), + walletId = oldWalletId, + ) + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "new-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(walletIdFor("new-native-xpub"), captor.firstValue.single().walletId) + } + @Test fun `connect preserves stored xpubs when account xpub refresh is partial`() = test { val previousXpubs = mapOf( diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 254089fb7d..69dd58e604 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -165,6 +165,37 @@ class TrezorBridgeTransportTest { assertEquals(listOf(0x01), result.data.toList()) } + @Test + fun `button ack call uses longer read timeout than bridge management requests`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 27u, byteArrayOf()) + + assertTrue(result.success) + assertEquals(18u.toUShort(), result.messageType) + assertEquals(listOf(0x01), result.data.toList()) + } + @Test fun `non signing call uses bridge management read timeout`() { server.route = { request -> diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index fa1bd073a7..ba2fb8a32b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -18,6 +18,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.ActivityWalletType import to.bitkit.repositories.TrezorRepo import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorWalletMode @@ -46,6 +47,7 @@ class TrezorViewModelTest : BaseUnitTest() { whenever(trezorRepo.needsPinEntry).thenReturn(needsPinEntryFlow) whenever(trezorRepo.walletMode).thenReturn(walletModeFlow) whenever(trezorRepo.watcherEvents).thenReturn(watcherEventsFlow) + whenever(trezorRepo.deriveWalletId(any())).thenReturn(ActivityWalletType.TREZOR.idPrefixed("test")) sut = createViewModel() } diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 57e7fbb6f0..eac97c73bb 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -3,23 +3,24 @@ package to.bitkit.viewmodels import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore import to.bitkit.ext.create import to.bitkit.ext.rawId +import to.bitkit.ext.scopedId +import to.bitkit.models.ActivityWalletType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.ActivityState -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.activity.components.ActivityTab @@ -29,34 +30,58 @@ import kotlin.test.assertEquals class ActivityListViewModelTest : BaseUnitTest() { private val activityRepo = mock() - private val hwWalletRepo = mock() private val pubkyRepo = mock() private val settingsStore = mock() - private val dbActivity = onchainActivity(id = "db1", txType = PaymentType.SENT, timestamp = 200uL) - private val hwActivity = onchainActivity(id = "hw1", txType = PaymentType.RECEIVED, timestamp = 100uL) - private lateinit var hardwareActivities: MutableStateFlow> + private val bitkitSent = onchainActivity(id = "bitkit-sent", txType = PaymentType.SENT, timestamp = 300uL) + private val bitkitReceived = onchainActivity( + id = "bitkit-received", + txType = PaymentType.RECEIVED, + timestamp = 250uL, + ) + + private val hwReceived = onchainActivity( + id = "hw-received", + txType = PaymentType.RECEIVED, + timestamp = 200uL, + walletId = ActivityWalletType.TREZOR.idPrefixed("dev1"), + ) + + private val hwSent = onchainActivity( + id = "hw-sent", + txType = PaymentType.SENT, + timestamp = 150uL, + walletId = ActivityWalletType.TREZOR.idPrefixed("dev1"), + ) + + private val hwSecondWallet = onchainActivity( + id = "hw-second-wallet", + txType = PaymentType.RECEIVED, + timestamp = 100uL, + walletId = ActivityWalletType.TREZOR.idPrefixed("dev2"), + ) + + private val activities = listOf(hwSecondWallet, bitkitSent, hwSent, bitkitReceived, hwReceived) @Before fun setUp() { - hardwareActivities = MutableStateFlow(persistentListOf(hwActivity)) whenever(activityRepo.state).thenReturn(MutableStateFlow(ActivityState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) whenever { activityRepo.syncActivities() }.thenReturn(Result.success(Unit)) - whenever { activityRepo.getTxIdsInBoostTxIds() }.thenReturn(emptySet()) + whenever { activityRepo.getTxIdsInBoostTxIds(any()) }.thenReturn(emptySet()) whenever { activityRepo.getActivities( - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), ) - }.thenReturn(Result.success(listOf(dbActivity))) - whenever(hwWalletRepo.activities).thenReturn(hardwareActivities) + }.thenReturn(Result.success(activities)) whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) whenever(settingsStore.isPaykitEnabled).thenReturn(MutableStateFlow(false)) } @@ -64,64 +89,143 @@ class ActivityListViewModelTest : BaseUnitTest() { private fun createViewModel() = ActivityListViewModel( bgDispatcher = testDispatcher, activityRepo = activityRepo, - hwWalletRepo = hwWalletRepo, pubkyRepo = pubkyRepo, settingsStore = settingsStore, ) @Test - fun `filtered activities merge hardware activities newest first`() = test { + fun `filtered activities include hardware activities newest first`() = test { val sut = createViewModel() advanceUntilIdle() - assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals( + listOf("bitkit-sent", "bitkit-received", "hw-received", "hw-sent", "hw-second-wallet"), + sut.filteredActivities.value?.map { it.rawId() }, + ) } @Test fun `filtered activities exclude hardware activities not matching the tab`() = test { + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = eq(PaymentType.SENT), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(bitkitSent, hwSent))) val sut = createViewModel() sut.setTab(ActivityTab.SENT) advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals(listOf("bitkit-sent", "hw-sent"), sut.filteredActivities.value?.map { it.rawId() }) } @Test - fun `filtered activities exclude hardware activities when a tag filter is active`() = test { + fun `hardware activity is included under an active tag filter`() = test { + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(hwReceived, hwSent, hwSecondWallet))) val sut = createViewModel() sut.toggleTag("tag1") advanceUntilIdle() - assertEquals(listOf("db1"), sut.filteredActivities.value?.map { it.rawId() }) + assertEquals( + listOf("hw-received", "hw-sent", "hw-second-wallet"), + sut.filteredActivities.value?.map { it.rawId() }, + ) } @Test - fun `hardware ids expose the hardware activity ids`() = test { + fun `hardware ids expose the ids of activities scoped to a hardware wallet`() = test { val sut = createViewModel() val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(setOf("hw1"), sut.hardwareIds.value) + assertEquals(setOf(hwReceived.scopedId(), hwSent.scopedId(), hwSecondWallet.scopedId()), sut.hardwareIds.value) job.cancel() } @Test - fun `hardware duplicates of local activities are excluded`() = test { - hardwareActivities.value = persistentListOf( - hwActivity, - onchainActivity(id = "db1", txType = PaymentType.RECEIVED, timestamp = 300uL), + fun `onchain activities remain scoped to Bitkit wallet`() = test { + val sut = createViewModel() + advanceUntilIdle() + + assertEquals( + listOf("bitkit-sent", "bitkit-received"), + sut.onchainActivities.value?.map { it.rawId() }, + ) + } + + @Test + fun `replaced transactions remain scoped by wallet`() = test { + val txId = "shared-tx" + val hardwareWalletId = ActivityWalletType.TREZOR.idPrefixed("dev1") + val bitkit = Activity.Onchain( + onchainActivity( + id = "bitkit-replaced", + txType = PaymentType.SENT, + timestamp = 200uL, + ).v1.copy( + txId = txId, + doesExist = false, + ), ) + val hardware = Activity.Onchain( + onchainActivity( + id = "hardware-replaced", + txType = PaymentType.SENT, + timestamp = 100uL, + walletId = hardwareWalletId, + ).v1.copy( + txId = txId, + doesExist = false, + ), + ) + whenever { + activityRepo.getActivities( + walletId = anyOrNull(), + filter = anyOrNull(), + txType = anyOrNull(), + tags = anyOrNull(), + search = anyOrNull(), + minDate = anyOrNull(), + maxDate = anyOrNull(), + limit = anyOrNull(), + sortDirection = anyOrNull(), + ) + }.thenReturn(Result.success(listOf(bitkit, hardware))) + whenever { activityRepo.getTxIdsInBoostTxIds(ActivityWalletType.BITKIT.id()) }.thenReturn(setOf(txId)) + whenever { activityRepo.getTxIdsInBoostTxIds(hardwareWalletId) }.thenReturn(emptySet()) + val sut = createViewModel() - val job = launch { sut.hardwareIds.collect {} } advanceUntilIdle() - assertEquals(listOf("db1", "hw1"), sut.filteredActivities.value?.map { it.rawId() }) - assertEquals(setOf("hw1"), sut.hardwareIds.value) - job.cancel() + assertEquals(listOf("hardware-replaced"), sut.filteredActivities.value?.map { it.rawId() }) } - private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + private fun onchainActivity( + id: String, + txType: PaymentType, + timestamp: ULong, + walletId: String = ActivityWalletType.BITKIT.id(), + ) = Activity.Onchain( + OnchainActivity.create( id = id, txType = txType, txId = id, @@ -130,6 +234,7 @@ class ActivityListViewModelTest : BaseUnitTest() { address = "bc1", timestamp = timestamp, confirmed = true, + walletId = walletId, ) ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f0ebda6e1c..b68ddbfa65 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -38,6 +38,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.models.ActivityWalletType import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.PubkyProfile @@ -279,16 +280,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" + val walletId = ActivityWalletType.TREZOR.idPrefixed("dev1") sut.mainScreenEffect.test { advanceUntilIdle() - hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL)) + hwReceivedTxs.emit(HwWalletReceivedTx(txid = txId, sats = 21uL, walletId = walletId)) advanceUntilIdle() assertEquals(txId, sut.transactionSheet.value.activityId) + assertEquals(walletId, sut.transactionSheet.value.activityWalletId) sut.onClickActivityDetail() - assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId)), awaitItem()) + assertEquals(MainScreenEffect.Navigate(Routes.ActivityDetail(txId, walletId)), awaitItem()) } verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index fb5486f698..676140af6a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -31,6 +31,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults +import to.bitkit.models.ActivityWalletType import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType @@ -223,6 +224,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) @@ -255,7 +258,9 @@ class TransferViewModelTest : BaseUnitTest() { eq(TXID), eq(MINING_FEE), eq(FEE_RATE), + eq(HW_WALLET_ID), ) + verify(hwWalletRepo).getWalletId(DEVICE_ID) verify(hwWalletRepo).ensureConnected(DEVICE_ID) } @@ -277,6 +282,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) @@ -300,6 +307,8 @@ class TransferViewModelTest : BaseUnitTest() { val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(RuntimeException("no device"))) whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) @@ -325,6 +334,8 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.getWalletId(DEVICE_ID)) + .thenReturn(Result.success(HW_WALLET_ID)) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) @@ -433,8 +444,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(TrezorException.UserCancelled())) whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)).thenReturn("reconnect title") - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)).thenReturn("reconnect body") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_title), + ).thenReturn("reconnect title") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_description), + ).thenReturn("reconnect body") sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -479,6 +494,7 @@ class TransferViewModelTest : BaseUnitTest() { const val DEVICE_ID = "dev1" const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." const val ERROR_TITLE = "Error" + val HW_WALLET_ID = ActivityWalletType.TREZOR.idPrefixed(DEVICE_ID) const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL diff --git a/changelog.d/next/1029.changed.md b/changelog.d/next/1029.changed.md new file mode 100644 index 0000000000..fcadac60aa --- /dev/null +++ b/changelog.d/next/1029.changed.md @@ -0,0 +1 @@ +Hardware wallet transactions are now first-class activity entries: they can be tagged, show their input and output details, and appear in the activity list under tag and tab filters alongside your normal Bitkit transactions. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 12d6411d6e..87f8d07655 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -60,13 +60,14 @@ Remove step forgets the device. | Journey | Covers | | - | - | | `connect-home-tile.xml` | Dev-screen connect, home tile, indicator, balance, detail screen opens | -| `activity-blue-icons.xml` | Hardware activity merge, blue icons, All Activity filters, current watch-only detail fallback | +| `activity-blue-icons.xml` | Hardware activity in the unified list, blue icons, All Activity tab filters | +| `activity-detail-hw-tags.xml` | Hardware activity detail tags (persist + survive tag filter) and Explore inputs/outputs | | `usb-reconnect.xml` | Disconnect indicator, injected USB attach intent → silent auto-reconnect; physical-device chooser path noted separately | | `suggestion-intro-sheet.xml` | Forget device, Hardware suggestion card, full connect flow (Intro → Searching → Found → Paired → Finish) re-pairs | | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, rename sheet, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | -| `transfer-to-spending.xml` | Happy-path transfer amount → sign → processing flow with a valid amount below the cap | +| `transfer-to-spending.xml` | Happy-path transfer amount → sign → same-backend confirmation → spending balance and hardware transfer activity | | `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | | `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | @@ -96,10 +97,94 @@ Current journeys pair the standard wallet. Hidden/passphrase wallet behavior is not asserted here yet; it needs explicit UX and identity-scoping coverage as described in synonymdev/bitkit-android#1030. +## Bridge transaction approval + +After tapping `Open Trezor Connect`, approve these four prompts on the Bridge emulator in +order: + +1. `Recipient #1` +2. `Amount #1` +3. `Confirm Locktime` +4. `Summary` + +Use the User Env dashboard or run this helper once for each prompt: + +```sh +../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes"}' +``` + +Complete all four approvals within the Bridge call's 120-second timeout. After approving +`Summary`, observe the Android screen immediately: `HardwareTransferSigned` is a one-second +handoff before the app advances to Processing Payment. + To exercise the received-money sheet (not covered by a journey because it needs an -out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, e.g. -send to an address generated via Dev Settings → Trezor → Get Address, then mine a block -with `./bitcoin-cli`. +out-of-band transfer), fund the emulator wallet through the same backend used by the app. +Generate the address from Dev Settings → Trezor → Get Address, then use Dev Settings → +Blocktank Regtest to deposit and mine the confirmation. + +## Hardware Activity Fixture + +Do not validate hardware activity journeys against a single transaction. Before running +`activity-blue-icons.xml`, `activity-detail-hw-tags.xml`, or the final assertions in +`transfer-to-spending.xml`, set up a mixed hardware history: + +- At least two confirmed hardware-wallet receive transactions with distinct amounts. +- At least one hardware-wallet Transfer To Spending transaction. +- Prefer at least one normal app activity in the unified Activity list when the run already + has one, so filters prove hardware and app-owned entries coexist. + +Create the two hardware receives through the app so setup and confirmation use the same +backend as Transfer To Spending: + +1. Open Settings → Dev Settings → Trezor and connect the known Bridge device. +2. Under Address Generation, tap `Get Address` and copy the generated address. +3. Return to Dev Settings → Blocktank Regtest, paste the address into `Address`, enter a + distinct amount, and tap `Make Deposit`. +4. Set the block count to `1`, tap `Mine Blocks`, and wait for the success message and the + hardware balance/activity to update. +5. Return to Trezor, tap `Next Index`, generate the next address, and repeat with a different + amount. + +When this fixture is used with Transfer To Spending, create the receive and transfer history +on the same backend described below. Use distinct amounts and note the latest transfer amount +before opening Activity rows. The hardware transfer row should render once with a blue hardware +icon, title `Transfer`, and subtitle `From Savings`; its detail screen should show +`TO SPENDING`. If the same transfer also appears as a separate default-wallet row with a +normal `TransferIcon` or the same amount appears twice for the same new tx, the journey should +fail. + +## Transfer To Spending backend rule + +The Transfer To Spending journey must use one regtest chain end to end. The app creates the +Blocktank order through the dev/staging Blocktank API, so the hardware wallet funding +transaction must also be composed, broadcast, funded and mined on the matching staging +Electrum/backend. + +For current dev runs, set the app Electrum server to: + +```sh +ssl://electrs.bitkit.stag0.blocktank.to:9999 +``` + +The journeys fund and mine through Dev Settings → Blocktank Regtest. The equivalent host-side +setup commands are: + +```sh +./lsp POST /regtest/chain/deposit '{"address":"","amountSat":25000000}' +./lsp POST /regtest/chain/mine '{"count":1}' +``` + +Do not run Transfer To Spending with the app pointed at local Electrum +`tcp://127.0.0.1:60001` while Blocktank is staging. That confirms the funding transaction on +the local chain only, leaves the staging Blocktank order unpaid, and strands the app on +Processing Payment. + +After hardware signing and broadcast, Processing Payment is an intermediate checkpoint only. +On regtest, tap `Continue Using Bitkit` within five seconds so the screen's automatic mine is +cancelled before it runs. Verify the app returns home and the new hardware Transfer / From +Savings row exists once with a pending or otherwise non-confirmed status. Then mine the same +backend, wait for the app/Core sync to observe the confirmation, verify Spending balance +updates, and verify the same hardware transfer row/detail becomes confirmed. For transfer-to-spending QA, explicitly cover the LSP cap boundary: the hardware wallet balance can be much larger than the displayed AVAILABLE amount because MAX is capped by diff --git a/journeys/hardware-wallet/activity-blue-icons.xml b/journeys/hardware-wallet/activity-blue-icons.xml index 1b91a4d0c0..f5fd12b1e0 100644 --- a/journeys/hardware-wallet/activity-blue-icons.xml +++ b/journeys/hardware-wallet/activity-blue-icons.xml @@ -1,39 +1,39 @@ - Verifies hardware wallet on-chain activity merged into the home list and the All - Activity screen with blue icon variants, filter behavior, and the current watch-only - activity detail fallback until Core-backed hardware activity support lands. Requires a - paired Bridge emulator whose wallet has at least one on-chain transaction (run - connect-home-tile.xml first; fund per README.md if the - deterministic wallet has no history). + Verifies hardware wallet on-chain activity in the home list and the All Activity screen + with blue icon variants, mixed transaction history, and tab filter behavior. Hardware + activities are first-class Bitkit Core activities persisted by the watcher, so they appear + in the unified list and survive filters like normal transactions. Requires a paired Bridge + emulator with the mixed hardware activity fixture from README.md: at least two confirmed + hardware receives with distinct amounts and one hardware Transfer To Spending transaction. Launch the Bitkit app and go to the wallet home screen - Verify the recent activity list contains at least one item whose circular icon is blue instead of orange + Verify the recent activity list contains at least one blue hardware activity row, then tap "Show All" beneath the activity list - Tap the first activity item with a blue icon + Verify the All Activity list contains at least three blue hardware rows from the fixture: two received rows with distinct amounts and one Transfer row with subtitle "From Savings" - Verify an activity detail screen opens showing a blue icon and an on-chain amount + Verify the hardware Transfer row appears only once in All Activity; fail if the same new transfer amount is also shown as a separate normal TransferIcon/default-wallet row - Navigate back, then tap "Show All" beneath the activity list + Tap the blue hardware Transfer row with subtitle "From Savings" - Verify the All Activity list also contains items with blue icons + Verify an activity detail screen opens showing a blue hardware icon, title "From Savings", status content, and "TO SPENDING", then navigate back to All Activity - Tap the "Sent" tab and verify no blue-icon item with a received (downward) arrow is listed + Tap the "Sent" tab and verify the blue hardware Transfer row is listed, while the two blue received rows are not listed - Tap the "Received" tab and verify blue-icon items with received arrows are listed, assuming the hardware wallet has incoming transactions + Tap the "Received" tab and verify the two distinct blue received rows are listed, while the hardware Transfer row is not listed - Apply any tag filter if a tag exists, and verify blue-icon hardware items disappear from the filtered list; skip this step if no tags exist + Tap back to the "All" tab and verify the blue hardware received rows and blue hardware Transfer row are listed again diff --git a/journeys/hardware-wallet/activity-detail-hw-tags.xml b/journeys/hardware-wallet/activity-detail-hw-tags.xml new file mode 100644 index 0000000000..9fbda2fe15 --- /dev/null +++ b/journeys/hardware-wallet/activity-detail-hw-tags.xml @@ -0,0 +1,42 @@ + + + Verifies that a hardware-wallet transaction behaves as a first-class Bitkit Core activity: + its detail screen supports tags (which persist and keep the item visible under a tag + filter) and its Explore screen shows the transaction inputs and outputs fetched from the + configured Electrum backend. Requires a paired Bridge emulator with the mixed hardware + activity fixture from README.md. Use a hardware seed distinct from the Bitkit wallet seed + so the transaction resolves as a hardware (blue-icon) activity, not a local one. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap "Show All" beneath the activity list + + + In All Activity, tap a blue hardware received activity row with a distinct amount, not the Transfer / From Savings row + + + Verify an activity detail screen opens showing a blue icon, a received on-chain amount, and status content for that selected hardware transaction + + + Tap "Add Tag", enter the tag "hwtest" and confirm it + + + Verify a tag chip labelled "hwtest" is shown on the activity detail screen + + + Navigate back to All Activity, then tap the same blue hardware received activity again and verify the "hwtest" tag is still shown (it persisted to Bitkit Core) + + + Tap "Explore", verify the Activity Explorer screen opens and shows an "Inputs" section and an "Outputs" section each listing at least one entry + + + Navigate back to All Activity + + + Open the tag filter, select the "hwtest" tag, and verify only the tagged blue hardware activity remains listed from the hardware fixture; the other untagged blue received or transfer rows should not match the tag filter + + + diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index 8d13bda3c6..8296b52f0b 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -4,7 +4,9 @@ the top bar, balance header, the Transfer To Spending entry when funds are present, the grouped activity list, and the Remove device confirm dialog. The final Remove step forgets the device, so run this last (re-run connect-home-tile.xml to pair - again). Requires a paired Bridge emulator (run connect-home-tile.xml first). + again). Requires a paired Bridge emulator (run connect-home-tile.xml first) and the mixed + hardware activity fixture from README.md, so this journey verifies multiple rows instead + of a single hardware transaction. @@ -17,10 +19,13 @@ Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it, verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it; if the first-run Transfer To Spending intro appears, tap "Get Started"; verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step - If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back + Verify the detail activity list shows multiple blue hardware rows for the paired wallet, including at least one received row and one Transfer / From Savings row, and does not show a duplicate normal TransferIcon row for the same hardware transfer amount + + + Tap one blue hardware row, verify an activity detail screen opens with a blue icon, then navigate back Tap the "Remove" button labelled with the device name near the bottom of the screen (testTag "RemoveHardwareWallet") diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml index a36e8485d6..874dd39ff2 100644 --- a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -3,13 +3,18 @@ Verifies that a funded hardware wallet whose balance is larger than the current Blocktank/LSP headroom is capped by the transferable spending limit, not by the device balance. Requires a paired Bridge emulator or physical Trezor with a hardware - balance larger than the available transfer limit, and at least one existing channel or - pending order consuming most of the regtest LSP cap. + balance larger than the available transfer limit, at least one existing channel or + pending order consuming most of the regtest LSP cap, and the mixed hardware activity + fixture from README.md. The app must use the staging Blocktank Electrum backend, and + funding and confirmation must use the app's Blocktank Regtest screen. Launch the Bitkit app and go to the wallet home screen + + Before starting, verify the home or hardware wallet detail Activity list already contains at least one blue hardware received row with a distinct amount; this prior-history control must remain visible after the capped transfer completes + Verify the hardware wallet tile shows a balance larger than the AVAILABLE amount expected in the transfer flow @@ -19,6 +24,9 @@ Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom @@ -35,10 +43,58 @@ Verify the sign screen opens (testTag "HardwareTransferSign"), showing NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL, without an insufficient-funds toast - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and approve every hardware-wallet prompt + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") + + + On the Bridge emulator, promptly approve the Recipient #1, Amount #1, Confirm Locktime and Summary prompts in that order, completing all four within 120 seconds + + + Immediately verify the one-second transaction signed handoff appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress as an intermediate state + + + Before mining, tap "Continue Using Bitkit" (testTag "TransferSuccess-button") and verify the app returns to the wallet home screen + + + Before mining, verify the recent Activity list contains exactly one hardware wallet transfer row for the new tx, with the blue hardware icon, title "Transfer", subtitle "From Savings", and a non-confirmed/non-stuck transfer status, above the prior blue received row from the fixture + + + Open the main menu from the wallet home screen + + + Tap "Settings" + + + Tap "Dev Settings" (testTag "DevSettings") + + + Scroll to the REGTEST section and tap "Blocktank Regtest" + + + Verify the "Blocktank Regtest" screen is shown and the block count is set to 1 + + + Tap "Mine Blocks" + + + Verify the success message says "Successfully mined 1 blocks" + + + Tap the top-bar back button to return to Dev Settings + + + Tap the top-bar back button to return to Settings + + + Tap the top-bar back button to return to the wallet home screen + + + Verify the app/Core sync observes the newly mined confirmation + + + Verify the SPENDING balance has increased by the capped AVAILABLE amount and the same hardware wallet transfer row for the new tx is now confirmed - Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress + Verify the capped transfer amount is not duplicated as a second default-wallet row with a normal TransferIcon or the same Transfer / From Savings text diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml index 518131da38..3b1448e1ba 100644 --- a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -3,7 +3,8 @@ Verifies that starting a hardware-wallet transfer while the Lightning node is still warming up does not fail or strand the CTA in loading. The flow should show the normal loading/progress UI, continue once the node reaches the needed state, and land on the - sign screen. Requires a paired and funded hardware wallet. + sign screen. Requires a paired and funded hardware wallet using the staging Blocktank + Electrum backend and the app's Blocktank Regtest funding controls. @@ -21,6 +22,9 @@ Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index 4c628a7b4b..70befd9bb7 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -1,23 +1,35 @@ Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With - Your Device -> Transaction Signed -> Processing Payment. The funding send is signed on the - Bridge emulator device, so no physical Trezor is required. Requires a paired Bridge emulator - (run connect-home-tile.xml first) whose native-segwit account holds spendable regtest funds, - and the bitkit-docker stack (Blocktank + regtest) running so the channel order can be created - and funded. Mirrors the on-chain Transfer To Spending flow, swapping local signing for the - device. + Your Device -> Transaction Signed -> Processing Payment -> same-backend confirmation -> + activity verification. The funding send is signed on the Bridge emulator device, so no + physical Trezor is required. Requires a paired Bridge emulator (run connect-home-tile.xml + first) whose native-segwit account holds spendable regtest funds on the staging Blocktank + regtest backend. Run with the mixed hardware activity fixture from README.md so the final + Activity assertions prove the new transfer coexists with older hardware receive rows and is + not duplicated as a default-wallet activity. The journey mines the funding transaction from + the app's Blocktank Regtest screen before checking the confirmed state. Mirrors the on-chain + Transfer To Spending flow, swapping local signing for the device. Launch the Bitkit app and go to the wallet home screen + + Before starting, verify the home or hardware wallet detail Activity list already contains at least one blue hardware received row with a distinct amount; this is the prior-history control used to detect ordering and filtering regressions after the new transfer + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + Verify the hardware wallet has a non-zero spendable balance + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + If the first-run Transfer To Spending intro appears, tap "Get Started" + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad @@ -31,13 +43,67 @@ Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and wait while the Bridge emulator composes, signs and broadcasts the funding transaction + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") + + + On the Bridge emulator, promptly approve the Recipient #1, Amount #1, Confirm Locktime and Summary prompts in that order, completing all four within 120 seconds + + + Immediately verify the one-second transaction signed handoff appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + + + Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears as an intermediate state, not as the final success condition + + + On the Processing Payment screen, immediately tap "Continue Using Bitkit" (testTag "TransferSuccess-button") within five seconds, before the regtest auto-mine runs, and verify the app returns to the wallet home screen + + + Before mining, verify the recent Activity list contains exactly one new hardware wallet transfer row for the new tx, with the blue hardware icon, title "Transfer", subtitle "From Savings", and a non-confirmed/non-stuck transfer status, above the prior blue received row from the fixture + + + Before mining, open the new hardware transfer row and verify the Activity detail shows "TO SPENDING" and a pending or otherwise non-confirmed transfer status, then navigate back to the wallet home screen + + + Open the main menu from the wallet home screen + + + Tap "Settings" + + + Tap "Dev Settings" (testTag "DevSettings") + + + Scroll to the REGTEST section and tap "Blocktank Regtest" + + + Verify the "Blocktank Regtest" screen is shown and the block count is set to 1 + + + Tap "Mine Blocks" + + + Verify the success message says "Successfully mined 1 blocks" + + + Tap the top-bar back button to return to Dev Settings + + + Tap the top-bar back button to return to Settings + + + Tap the top-bar back button to return to the wallet home screen + + + Verify the app/Core sync observes the newly mined confirmation + + + Verify the SPENDING balance has increased by the transfer amount and the same hardware wallet transfer row for the new tx is now confirmed - Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + Verify the new transfer amount is not duplicated as a second default-wallet row with a normal TransferIcon or the same Transfer / From Savings text - Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears + Open the same hardware transfer row again and verify the Activity detail shows "TO SPENDING" and status "Confirmed"