diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 8b99d5ff79..f6527a5212 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -5,6 +5,7 @@ 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 fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id @@ -94,6 +95,7 @@ enum class BoostType { RBF, CPFP } @Suppress("LongParameterList") fun LightningActivity.Companion.create( + walletId: String = WalletScope.default, id: String, txType: PaymentType, status: PaymentState, @@ -108,6 +110,7 @@ fun LightningActivity.Companion.create( updatedAt: ULong? = createdAt, seenAt: ULong? = null, ) = LightningActivity( + walletId = walletId, id = id, txType = txType, status = status, @@ -125,6 +128,7 @@ fun LightningActivity.Companion.create( @Suppress("LongParameterList") fun OnchainActivity.Companion.create( + walletId: String = WalletScope.default, id: String, txType: PaymentType, txId: String, @@ -146,6 +150,7 @@ fun OnchainActivity.Companion.create( updatedAt: ULong? = createdAt, seenAt: ULong? = null, ) = OnchainActivity( + walletId = walletId, id = id, txType = txType, txId = txId, diff --git a/app/src/main/java/to/bitkit/ext/BackupRestoreCompat.kt b/app/src/main/java/to/bitkit/ext/BackupRestoreCompat.kt new file mode 100644 index 0000000000..87ce81e98a --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/BackupRestoreCompat.kt @@ -0,0 +1,45 @@ +package to.bitkit.ext + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import to.bitkit.di.json +import to.bitkit.models.ActivityBackupV1 +import to.bitkit.models.MetadataBackupV1 +import to.bitkit.models.WalletScope + +fun String.decodeActivityBackupV1Compat(): ActivityBackupV1 = + json.decodeFromString(normalizeLegacyWalletIdsInBackupJson(this)) + +fun String.decodeMetadataBackupV1Compat(): MetadataBackupV1 = + json.decodeFromString(normalizeLegacyWalletIdsInBackupJson(this)) + +private fun normalizeLegacyWalletIdsInBackupJson( + raw: String, + walletId: String = WalletScope.default, +): String { + val normalized = json.parseToJsonElement(raw).normalizeLegacyWalletIds(walletId) + return json.encodeToString(normalized) +} + +private fun JsonElement.normalizeLegacyWalletIds(walletId: String): JsonElement = when (this) { + is JsonObject -> { + val patched = if (needsLegacyWalletId() && "walletId" !in this) { + this + ("walletId" to JsonPrimitive(walletId)) + } else { + this + } + JsonObject(patched.mapValues { (_, value) -> value.normalizeLegacyWalletIds(walletId) }) + } + is JsonArray -> JsonArray(map { it.normalizeLegacyWalletIds(walletId) }) + else -> this +} + +private fun JsonObject.needsLegacyWalletId(): Boolean = when { + "paymentId" in this && "tags" in this && "isReceive" in this -> true + "activityId" in this && "tags" in this && "paymentId" !in this -> true + "txId" in this && "txType" in this && "value" in this -> true + "invoice" in this && "status" in this && "txType" in this -> true + else -> false +} diff --git a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt new file mode 100644 index 0000000000..ff508539a3 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt @@ -0,0 +1,17 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorException + +fun Throwable.isTrezorUserCancellation(): Boolean { + var current: Throwable? = this + while (current != null) { + when (current) { + is TrezorException.UserCancelled, + is TrezorException.PinCancelled, + is TrezorException.PassphraseCancelled, + -> return true + } + current = current.cause + } + return false +} diff --git a/app/src/main/java/to/bitkit/models/HwWalletId.kt b/app/src/main/java/to/bitkit/models/HwWalletId.kt new file mode 100644 index 0000000000..83e06cee28 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwWalletId.kt @@ -0,0 +1,10 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.deriveWalletId + +object HwWalletId { + fun derive(xpubs: Map, deviceType: String = "trezor"): String { + require(xpubs.isNotEmpty()) { "xpubs must not be empty" } + return deriveWalletId(deviceType = deviceType, xpubs = xpubs.values.toList()) + } +} diff --git a/app/src/main/java/to/bitkit/models/WalletScope.kt b/app/src/main/java/to/bitkit/models/WalletScope.kt new file mode 100644 index 0000000000..d0a3c7b16e --- /dev/null +++ b/app/src/main/java/to/bitkit/models/WalletScope.kt @@ -0,0 +1,14 @@ +package to.bitkit.models + +import androidx.annotation.VisibleForTesting +import com.synonym.bitkitcore.getDefaultWalletId + +object WalletScope { + @VisibleForTesting + internal var testOverride: String? = null + + val default: String + get() = testOverride ?: lazyDefault + + private val lazyDefault: String by lazy { getDefaultWalletId() } +} diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7dfb8f4d10..9de8bc1ba4 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher 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 @@ -653,7 +654,7 @@ class ActivityRepo @Inject constructor( val now = nowTimestamp().epochSecond.toULong() insertActivity( Activity.Lightning( - LightningActivity( + LightningActivity.create( id = id, txType = PaymentType.RECEIVED, status = PaymentState.SUCCEEDED, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index efa850da5f..8a2ee7e835 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -35,6 +35,8 @@ import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json +import to.bitkit.ext.decodeActivityBackupV1Compat +import to.bitkit.ext.decodeMetadataBackupV1Compat import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis import to.bitkit.models.ActivityBackupV1 @@ -581,7 +583,7 @@ class BackupRepo @Inject constructor( val result = runCatching { performRestore(BackupCategory.METADATA) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) + val parsed = String(dataBytes).decodeMetadataBackupV1Compat() val cleanCache = parsed.cache.resetBip21() // Force address rotation cacheStore.update { cleanCache } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) @@ -613,7 +615,7 @@ class BackupRepo @Inject constructor( parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) + val parsed = String(dataBytes).decodeActivityBackupV1Compat() activityRepo.restoreFromBackup(parsed) parsed.createdAt } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index f4992921c0..0f3f0a7dfc 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -4,12 +4,9 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction -import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WatcherEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -37,7 +34,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env -import to.bitkit.ext.create +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HwFundingAccount @@ -86,12 +83,16 @@ class HwWalletRepo @Inject constructor( private const val WATCHER_ID_SEPARATOR = "|" private val WATCHER_START_RETRY_DELAY = 30.seconds const val DEVICE_LABEL_MAX_LENGTH = 50 + + /** Trezor v1 (2.4.0) tracks native segwit only; multi-type HW support is follow-up work. */ + private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey) } private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) private val activeWatchers = mutableSetOf() private val activeWatcherElectrumUrls = mutableMapOf() + private val activeWatcherWalletIds = mutableMapOf() private val retryingWatcherStarts = mutableSetOf() private val watcherSyncRequests = MutableSharedFlow(extraBufferCapacity = 1) private val _watcherData = MutableStateFlow>(emptyMap()) @@ -107,6 +108,8 @@ class HwWalletRepo @Inject constructor( fun onAppForegrounded() = trezorRepo.onAppForegrounded() + fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId) + suspend fun resetState() = withContext(ioDispatcher) { activeWatchers.toList().forEach { watcherId -> trezorRepo.stopWatcher(watcherId) @@ -114,6 +117,7 @@ class HwWalletRepo @Inject constructor( } activeWatchers.clear() activeWatcherElectrumUrls.clear() + activeWatcherWalletIds.clear() retryingWatcherStarts.clear() emittedReceivedTxIds.clear() _watcherData.update { emptyMap() } @@ -153,6 +157,8 @@ class HwWalletRepo @Inject constructor( suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(deviceId) + suspend fun getFundingAccount( deviceId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, @@ -225,7 +231,10 @@ class HwWalletRepo @Inject constructor( ).getOrThrow() } if (signed.isFailure) { - trezorRepo.disconnectStaleSession(deviceId) + val failure = signed.exceptionOrNull() + if (failure?.isTrezorUserCancellation() != true) { + trezorRepo.disconnectStaleSession(deviceId) + } } val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() HwFundingBroadcastResult( @@ -352,14 +361,11 @@ class HwWalletRepo @Inject constructor( trezorRepo.watcherEvents.collect { (watcherId, event) -> if (event !is WatcherEvent.TransactionsChanged) return@collect val previous = _watcherData.value[watcherId] - val activities = event.transactions - .map { it.toOnchainActivity(clock, previous?.activities.orEmpty()) } - .toImmutableList() + val activities = event.activities.toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, - transactions = event.transactions.toImmutableList(), activities = activities, ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) @@ -367,7 +373,7 @@ class HwWalletRepo @Inject constructor( activities.filterIsInstance().forEach { activityRepo.syncHardwareOnchainActivity(it.v1) } - emitReceivedTxs(previous, event, updatedWatcherData) + emitReceivedTxs(previous, activities, updatedWatcherData) } } } @@ -378,21 +384,21 @@ class HwWalletRepo @Inject constructor( */ private suspend fun emitReceivedTxs( previous: HwWatcherData?, - event: WatcherEvent.TransactionsChanged, + activities: List, watcherData: Map, ) { if (previous == null) return - val knownTxIds = previous.activities.map { it.rawId() }.toSet() + val knownTxIds = previous.activities.mapNotNull { activity -> + (activity as? Activity.Onchain)?.v1?.txId + }.toSet() val mergedActivities = watcherData.values.toList().toMergedActivities() - event.transactions - .filter { - it.direction == TxDirection.RECEIVED && - it.txid !in knownTxIds && - emittedReceivedTxIds.add(it.txid) - } - .forEach { - val sats = mergedActivities.findOnchain(it.txid)?.v1?.value ?: it.amount - _receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = sats)) + 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)) } } @@ -413,22 +419,34 @@ class HwWalletRepo @Inject constructor( ) { desired, _ -> desired }.collect { (knownDevices, watcherSettings) -> - // Only watch the address types the user monitors (Settings > Advanced > Address Type), - // mirroring the on-chain wallet. Xpubs for all types are still captured on connect, so - // toggling a type on later starts its watcher without reconnecting the device. + // Trezor v1 watches native segwit only (not global Advanced address-type monitoring). + // Xpubs for all types are still captured on connect for a future multi-type release. // Device entries sharing an xpub (same device on bluetooth and usb) watch it only once. val filtered = knownDevices.flatMap { device -> + val walletId = device.resolvedWalletId() device.xpubs - .filterKeys { it in watcherSettings.monitoredTypes } + .filterKeys { it in SUPPORTED_WATCHER_ADDRESS_TYPES } .map { (addressType, xpub) -> - WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl) + WatcherSpec( + deviceId = device.id, + addressType = addressType, + xpub = xpub, + electrumUrl = watcherSettings.electrumUrl, + walletId = walletId, + ) } }.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) return@forEach + if ( + isActive && + activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl && + activeWatcherWalletIds[spec.watcherId] == spec.walletId + ) { + return@forEach + } if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach trezorRepo.startWatcher( @@ -437,9 +455,11 @@ class HwWalletRepo @Inject constructor( network = Env.network.toCoreNetwork(), accountType = spec.addressType.toAddressType()?.toAccountType(), electrumUrl = spec.electrumUrl, + walletId = spec.walletId, ).onSuccess { activeWatchers += spec.watcherId activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl + activeWatcherWalletIds[spec.watcherId] = spec.walletId retryingWatcherStarts -= spec.watcherId }.onFailure { Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG) @@ -460,6 +480,7 @@ class HwWalletRepo @Inject constructor( trezorRepo.stopWatcher(watcherId).onSuccess { activeWatchers -= watcherId activeWatcherElectrumUrls -= watcherId + activeWatcherWalletIds -= watcherId _watcherData.update { it - watcherId } }.isSuccess @@ -473,57 +494,57 @@ class HwWalletRepo @Inject constructor( } } - private fun HistoryTransaction.toOnchainActivity(clock: Clock, previousActivities: List): Activity { - val activityTimestamp = timestamp ?: previousActivities.findOnchain(txid)?.v1?.timestamp - ?: clock.now().epochSeconds.toULong() - return listOf(this).toOnchainActivity( - timestamp = activityTimestamp, - sourceActivities = previousActivities, - ) - } + private fun KnownDevice.resolvedWalletId(): String = + walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs, id) - private fun List.toMergedActivities(): List { - val sourceActivities = flatMap { it.activities } - return flatMap { it.transactions } - .groupBy { it.txid } + private fun List.toMergedActivities(): List = + flatMap { it.activities } + .groupBy { it.rawId() } .values - .map { transactions -> - val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull() - ?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp - ?: 0uL - transactions.toOnchainActivity(timestamp, sourceActivities) - } - } - - private fun List.toOnchainActivity( - timestamp: ULong, - sourceActivities: List, - ): Activity { - val first = first() - val received = fold(0uL) { acc, tx -> acc.safe() + tx.received.safe() } - val sent = fold(0uL) { acc, tx -> acc.safe() + tx.sent.safe() } - val fee = mapNotNull { it.fee }.maxOrNull() ?: 0uL - val type = when { + .map { it.mergedActivity() } + + private fun List.mergedActivity(): Activity { + if (size == 1) return first() + + val onchainActivities = filterIsInstance() + if (onchainActivities.size != size) return first() + + val base = onchainActivities.minBy { it.v1.timestamp } + val received = onchainActivities.filter { it.v1.txType == PaymentType.RECEIVED } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val sent = onchainActivities.filter { it.v1.txType == PaymentType.SENT } + .fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() } + val fee = onchainActivities.maxOf { it.v1.fee } + val feeRate = onchainActivities.maxOf { it.v1.feeRate } + val txType = when { received > sent -> PaymentType.RECEIVED - else -> PaymentType.SENT + sent > received -> PaymentType.SENT + else -> base.v1.txType } - val value = when (type) { + val value = when (txType) { PaymentType.RECEIVED -> received.safe() - sent.safe() - PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe() + PaymentType.SENT -> sent.safe() - received.safe() } - val confirmations = maxOf { it.confirmations } - val sourceActivity = sourceActivities.findOnchain(first.txid) + return Activity.Onchain( - OnchainActivity.create( - id = first.txid, - txType = type, - txId = first.txid, + base.v1.copy( + txType = txType, value = value, fee = fee, - address = "", - timestamp = timestamp, - confirmed = confirmations > 0u, - confirmTimestamp = sourceActivity?.v1?.confirmTimestamp, + feeRate = feeRate, + address = onchainActivities.firstOrNull { it.v1.address.isNotBlank() }?.v1?.address.orEmpty(), + confirmed = onchainActivities.any { it.v1.confirmed }, + isBoosted = onchainActivities.any { it.v1.isBoosted }, + boostTxIds = onchainActivities.flatMap { it.v1.boostTxIds }.distinct(), + isTransfer = onchainActivities.any { it.v1.isTransfer }, + doesExist = onchainActivities.any { it.v1.doesExist }, + confirmTimestamp = onchainActivities.mapNotNull { it.v1.confirmTimestamp }.maxOrNull(), + channelId = onchainActivities.firstNotNullOfOrNull { it.v1.channelId }, + transferTxId = onchainActivities.firstNotNullOfOrNull { it.v1.transferTxId }, + contact = onchainActivities.firstNotNullOfOrNull { it.v1.contact }, + createdAt = onchainActivities.mapNotNull { it.v1.createdAt }.minOrNull(), + updatedAt = onchainActivities.mapNotNull { it.v1.updatedAt }.maxOrNull(), + seenAt = onchainActivities.mapNotNull { it.v1.seenAt }.minOrNull(), ) ) } @@ -536,6 +557,7 @@ class HwWalletRepo @Inject constructor( val addressType: String, val xpub: String, val electrumUrl: String, + val walletId: String, ) { val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" } @@ -577,6 +599,5 @@ private data class HwWatcherData( val deviceId: String, val addressType: String, val balanceSats: ULong, - val transactions: ImmutableList, val activities: ImmutableList, ) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 127a992b06..1175d9fcad 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -88,6 +88,7 @@ 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 @@ -1178,6 +1179,7 @@ class LightningRepo @Inject constructor( val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) val preActivityMetadata = PreActivityMetadata( + walletId = WalletScope.default, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index d0fa79b790..a5c60d01b5 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -11,6 +11,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.services.CoreService +import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -127,11 +128,13 @@ class PreActivityMetadataRepo @Inject constructor( feeRate: ULong? = null, isTransfer: Boolean = false, channelId: String? = null, + walletId: String = WalletScope.default, ): Result = withContext(ioDispatcher) { return@withContext runCatching { require(tags.isNotEmpty() || isTransfer) val preActivityMetadata = PreActivityMetadata( + walletId = walletId, paymentId = id, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1bb77cf30b..e8f6f0af05 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -33,7 +33,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope 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 @@ -51,6 +53,7 @@ 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.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType @@ -69,10 +72,11 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File -import java.util.UUID +import to.bitkit.models.HwWalletId import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -98,6 +102,8 @@ class TrezorRepo @Inject constructor( private const val WALLET_MODE_RECONNECT_DELAY_MS = 1_000L private const val TRANSPORT_RESTORED_MAX_ATTEMPTS = 4 private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds + private val CONNECT_ATTEMPT_POLL_INTERVAL = 250.milliseconds + private val CONNECT_ATTEMPT_MAX_WAIT = 28.seconds } private val _state = MutableStateFlow(TrezorState()) @@ -641,8 +647,9 @@ class TrezorRepo @Inject constructor( suspend fun connectKnownDevice( deviceId: String, forceSession: Boolean = false, + allowBleFallback: Boolean = true, ): Result = withContext(ioDispatcher) { - if (_state.value.isConnecting) { + if (isConnectInProgress()) { return@withContext Result.failure(AppError("Connection already in progress")) } var startedConnecting = false @@ -661,16 +668,7 @@ class TrezorRepo @Inject constructor( Logger.debug("Scanning for reconnect devices", context = TAG) val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } val knownDevice = knownDevices.find { it.matches(deviceId) } - val scannedDevices = trezorService.scan() - Logger.debug( - "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", - context = TAG, - ) - // Honor the transport the user selected — connect to exactly the - // entry they tapped instead of overriding Bluetooth with USB. - val device = scannedDevices.find { it.id == deviceId } - ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() - ?: throw AppError("Device not found nearby — is it powered on?") + val device = resolveKnownReconnectDevice(deviceId, knownDevice, allowBleFallback) Logger.debug("Found reconnect device '${device.id}'", context = TAG) Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) @@ -682,6 +680,9 @@ class TrezorRepo @Inject constructor( }.onFailure { e -> Logger.error("Connect known device failed", e, context = TAG) _state.update { it.copy(error = e.message) } + if (!forceSession) { + disconnectStaleSession(deviceId) + } } } finally { if (startedConnecting) { @@ -691,11 +692,96 @@ class TrezorRepo @Inject constructor( } suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { + connectedFeatures(deviceId)?.let { return@withContext Result.success(it) } + if (isConnectInProgress()) { + awaitInFlightConnect(deviceId) + connectedFeatures(deviceId)?.let { return@withContext Result.success(it) } + } + if (isKnownBluetoothDevice(deviceId)) { + return@withContext reconnectKnownBluetoothDevice(deviceId) + } + connectKnownDevice(deviceId, forceSession = true) + } + + /** + * BLE Trezors often need a few seconds to advertise again after unlock, so retry + * with growing delays (same cadence as [retryAutoReconnect]) instead of failing on + * the first empty scan or a premature direct-address connect. + */ + private suspend fun reconnectKnownBluetoothDevice(deviceId: String): Result { + var lastFailure: Throwable? = null + repeat(TRANSPORT_RESTORED_MAX_ATTEMPTS) { attempt -> + if (attempt > 0) { + delay(TRANSPORT_RESTORED_RECONNECT_DELAY * attempt) + } + connectedFeatures(deviceId)?.let { return Result.success(it) } + if (isConnectInProgress()) { + awaitInFlightConnect(deviceId) + connectedFeatures(deviceId)?.let { return Result.success(it) } + } + val allowBleFallback = attempt == TRANSPORT_RESTORED_MAX_ATTEMPTS - 1 + val result = connectKnownDevice( + deviceId = deviceId, + forceSession = attempt == 0, + allowBleFallback = allowBleFallback, + ) + if (result.isSuccess) return result + val failure = result.exceptionOrNull() + if (failure?.isTrezorUserCancellation() == true) { + return Result.failure(failure) + } + lastFailure = failure + } + return Result.failure(lastFailure ?: AppError("Failed to connect")) + } + + suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = withContext(ioDispatcher) { + (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + .any { it.matches(deviceId) && it.transportType == TransportType.BLUETOOTH } + } + + fun deriveWalletId(xpubs: Map, deviceId: String = ""): String = + deriveHardwareWalletId(xpubs, deviceId) + + private suspend fun connectedFeatures(deviceId: String): TrezorFeatures? { val current = _state.value.connected - if (current?.id == deviceId && trezorService.isConnected()) { - return@withContext Result.success(current.features) + return if (current?.id == deviceId && trezorService.isConnected()) current.features else null + } + + private suspend fun awaitInFlightConnect(deviceId: String) { + transportReconnectJob?.takeIf { it.isActive }?.join() + waitForConnectAttempt(deviceId) + } + + private suspend fun waitForConnectAttempt(deviceId: String) { + try { + withTimeout(CONNECT_ATTEMPT_MAX_WAIT) { + while (true) { + if (connectedFeatures(deviceId) != null) return@withTimeout + if (!isConnectInProgress()) return@withTimeout + delay(CONNECT_ATTEMPT_POLL_INTERVAL) + } + } + } catch (e: TimeoutCancellationException) { + // Expected: in-flight connect ran longer than our wait window; caller will retry. + } + } + + private suspend fun resolveKnownReconnectDevice( + deviceId: String, + knownDevice: KnownDevice?, + allowBleFallback: Boolean = true, + ): TrezorDeviceInfo { + val scannedDevices = trezorService.scan() + Logger.debug( + "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", + context = TAG, + ) + scannedDevices.find { it.id == deviceId }?.let { return it } + if (allowBleFallback) { + knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo()?.let { return it } } - connectKnownDevice(deviceId, forceSession = false) + throw AppError("Device not found nearby — is it powered on?") } suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { @@ -742,11 +828,13 @@ class TrezorRepo @Inject constructor( gapLimit: UInt = 20u, accountType: AccountType? = null, electrumUrl: String = electrumUrlForNetwork(network), + walletId: String, ): Result = withContext(ioDispatcher) { runCatching { awaitSetup() val params = WatcherParams( watcherId = watcherId, + walletId = walletId, extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -835,6 +923,21 @@ class TrezorRepo @Inject constructor( } } + /** Pre-connects one known BLE Trezor before the transfer sign screen asks for it. */ + fun warmUpKnownDevice(deviceId: String) { + scope.launch { + if (connectedFeatures(deviceId) != null) return@launch + if (isConnectInProgress()) return@launch + if (!hasKnownDevice(deviceId)) return@launch + if (!isKnownBluetoothDevice(deviceId)) return@launch + + Logger.info("Warming up known bluetooth device '$deviceId'", context = TAG) + ensureConnected(deviceId).onFailure { + Logger.debug("Warm up connect failed for '$deviceId'", context = TAG) + } + } + } + /** * Serializes reconnect triggers into one in-flight retry loop. A Trezor * re-enumerates USB during its unlock flow, so a single replug delivers several @@ -1007,6 +1110,10 @@ class TrezorRepo @Inject constructor( } suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { + val connectedId = _state.value.connected?.id + if (connectedId != null && connectedId != deviceId) { + return@withContext Result.success(Unit) + } val result = runSuspendCatching { trezorService.disconnect() disconnectTransportDevice(deviceId) @@ -1086,13 +1193,22 @@ private val KnownDevice.walletKey: String private fun walletKey(xpubs: Map, fallback: String): String = xpubs.values.sorted().joinToString().ifEmpty { fallback } +private fun deriveHardwareWalletId(xpubs: Map, deviceId: String = ""): String = + runCatching { HwWalletId.derive(xpubs) }.getOrElse { + if (xpubs.isNotEmpty()) walletKey(xpubs, deviceId) else newHardwareWalletId() + } + 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() } - ?: newHardwareWalletId() + ?: deriveHardwareWalletId(xpubs, deviceId) } +private fun newHardwareWalletId(): String = runCatching { + HwWalletId.derive(mapOf("fallback" to java.util.UUID.randomUUID().toString())) +}.getOrElse { java.util.UUID.randomUUID().toString() } + private fun List.withHardwareWalletIds(): List { val existingByWallet = filter { it.walletId.isNotBlank() } .associate { it.walletKey to it.walletId } @@ -1100,13 +1216,13 @@ private fun List.withHardwareWalletIds(): List { return map { val walletId = existingByWallet[it.walletKey] - ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + ?: generatedByWallet.getOrPut(it.walletKey) { + deriveHardwareWalletId(it.xpubs, it.id) + } if (it.walletId == walletId) it else it.copy(walletId = walletId) } } -private fun newHardwareWalletId(): String = UUID.randomUUID().toString() - private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, transportType = transportType.toCoreTransportType(), diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6c53356f5f..f606cc39dc 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -44,6 +44,7 @@ import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils +import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.measured @@ -236,6 +237,7 @@ class WalletRepo @Inject constructor( }.getOrNull() val preActivityMetadata = PreActivityMetadata( + walletId = WalletScope.default, paymentId = paymentId, createdAt = nowTimestamp().toEpochMilli().toULong(), tags = tags, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b11bddae8d..c5540516b9 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -86,6 +86,7 @@ import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid 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 @@ -242,10 +243,13 @@ class ActivityService( private val settingsStore: SettingsStore, private val privatePaykitContactResolver: Provider, ) { + private val walletId = WalletScope.default + suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one val activities = getActivities( + walletId = walletId, filter = ActivityFilter.ALL, txType = null, tags = null, @@ -253,14 +257,14 @@ class ActivityService( minDate = null, maxDate = null, limit = null, - sortDirection = null + sortDirection = null, ) for (activity in activities) { val id = when (activity) { is Activity.Lightning -> activity.v1.id is Activity.Onchain -> activity.v1.id } - deleteActivityById(activityId = id) + deleteActivityById(walletId = walletId, activityId = id) } } } @@ -300,6 +304,7 @@ class ActivityService( ) } return BitkitCoreTransactionDetails( + walletId = walletId, txId = txid, amountSats = details.amountSats, inputs = inputs, @@ -308,15 +313,15 @@ class ActivityService( } suspend fun getTransactionDetails(txid: String): BitkitCoreTransactionDetails? = ServiceQueue.CORE.background { - getBitkitCoreTransactionDetails(txid) + getBitkitCoreTransactionDetails(walletId = walletId, txId = txid) } suspend fun getActivity(id: String): Activity? = ServiceQueue.CORE.background { - getActivityById(id) + getActivityById(walletId = walletId, activityId = id) } suspend fun getOnchainActivityByTxId(txId: String): OnchainActivity? = ServiceQueue.CORE.background { - getActivityByTxId(txId = txId) + getActivityByTxId(walletId = walletId, txId = txId) } suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { @@ -338,29 +343,39 @@ class ActivityService( limit: UInt? = null, sortDirection: SortDirection? = null, ): List = ServiceQueue.CORE.background { - getActivities(filter, txType, tags, search, minDate, maxDate, limit, sortDirection) + getActivities( + walletId = walletId, + filter = filter, + txType = txType, + tags = tags, + search = search, + minDate = minDate, + maxDate = maxDate, + limit = limit, + sortDirection = sortDirection, + ) } suspend fun update(id: String, activity: Activity) = ServiceQueue.CORE.background { - updateActivity(id, activity) + updateActivity(activityId = id, activity = activity) } suspend fun delete(id: String): Boolean = ServiceQueue.CORE.background { - deleteActivityById(id) + deleteActivityById(walletId = walletId, activityId = id) } suspend fun appendTags(toActivityId: String, tags: List): Result = runCatching { ServiceQueue.CORE.background { - addTags(toActivityId, tags) + addTags(walletId = walletId, activityId = toActivityId, tags = tags) } } suspend fun dropTags(fromActivityId: String, tags: List) = ServiceQueue.CORE.background { - removeTags(fromActivityId, tags) + removeTags(walletId = walletId, activityId = fromActivityId, tags = tags) } suspend fun tags(forActivityId: String): List = ServiceQueue.CORE.background { - getTags(forActivityId) + getTags(walletId = walletId, activityId = forActivityId) } suspend fun allPossibleTags(): List = ServiceQueue.CORE.background { @@ -388,26 +403,38 @@ class ActivityService( } suspend fun addPreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.addPreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.addPreActivityMetadataTags( + walletId = walletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun removePreActivityMetadataTags(paymentId: String, tags: List) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.removePreActivityMetadataTags(paymentId = paymentId, tags = tags) + com.synonym.bitkitcore.removePreActivityMetadataTags( + walletId = walletId, + paymentId = paymentId, + tags = tags, + ) } suspend fun resetPreActivityMetadataTags(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.resetPreActivityMetadataTags(paymentId = paymentId) + com.synonym.bitkitcore.resetPreActivityMetadataTags(walletId = walletId, paymentId = paymentId) } suspend fun getPreActivityMetadata( searchKey: String, searchByAddress: Boolean = false, ): PreActivityMetadata? = ServiceQueue.CORE.background { - com.synonym.bitkitcore.getPreActivityMetadata(searchKey = searchKey, searchByAddress = searchByAddress) + com.synonym.bitkitcore.getPreActivityMetadata( + walletId = walletId, + searchKey = searchKey, + searchByAddress = searchByAddress, + ) } suspend fun deletePreActivityMetadata(paymentId: String) = ServiceQueue.CORE.background { - com.synonym.bitkitcore.deletePreActivityMetadata(paymentId = paymentId) + com.synonym.bitkitcore.deletePreActivityMetadata(walletId = walletId, paymentId = paymentId) } suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { @@ -510,7 +537,7 @@ class ActivityService( return } - val existingActivity = getActivityById(payment.id) + val existingActivity = getActivityById(walletId = walletId, activityId = payment.id) if (existingActivity is Activity.Lightning) { val statusChanging = existingActivity.v1.status != state val needsPrivateContactAttribution = existingActivity.v1.contact == null && @@ -550,8 +577,8 @@ class ActivityService( ) } - if (getActivityById(payment.id) != null) { - updateActivity(payment.id, Activity.Lightning(ln)) + if (getActivityById(walletId = walletId, activityId = payment.id) != null) { + updateActivity(activityId = payment.id, activity = Activity.Lightning(ln)) } else { upsertActivity(Activity.Lightning(ln)) } @@ -890,7 +917,7 @@ class ActivityService( val timestamp = payment.latestUpdateTimestamp val confirmationData = getConfirmationStatus(kind, timestamp) - var existingActivity = getActivityById(payment.id) + var existingActivity = getActivityById(walletId = walletId, activityId = payment.id) if (existingActivity == null) { getOnchainActivityByTxId(kind.txid)?.let { existingActivity = Activity.Onchain(it) @@ -956,7 +983,7 @@ class ActivityService( if (existingActivity != null && existingActivity is Activity.Onchain) { val existingOnchain = existingActivity.v1 - updateActivity(existingOnchain.id, Activity.Onchain(onChain)) + updateActivity(activityId = existingOnchain.id, activity = Activity.Onchain(onChain)) } else { upsertActivity(Activity.Onchain(onChain)) } @@ -1203,7 +1230,7 @@ class ActivityService( isBoosted = false, updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(replacedActivity.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = replacedActivity.id, activity = Activity.Onchain(updatedActivity)) Logger.info("Marked transaction $txid as replaced", context = TAG) } else { Logger.info( @@ -1263,7 +1290,7 @@ class ActivityService( contact = replacementActivity.contact ?: replacedActivity?.contact, updatedAt = System.currentTimeMillis().toULong() / 1000u, ) - updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = replacementActivity.id, activity = Activity.Onchain(updatedActivity)) if (replacedActivity != null) { copyTagsFromReplacedActivity(txid, conflictTxid, replacedActivity.id, replacementActivity.id) @@ -1307,7 +1334,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction reorged for $txid", e, context = TAG) } @@ -1327,7 +1354,7 @@ class ActivityService( updatedAt = System.currentTimeMillis().toULong() / 1000u ) - updateActivity(onchain.id, Activity.Onchain(updatedActivity)) + updateActivity(activityId = onchain.id, activity = Activity.Onchain(updatedActivity)) }.onFailure { e -> Logger.error("Error handling onchain transaction evicted for $txid", e, context = TAG) } @@ -1390,7 +1417,7 @@ class ActivityService( } suspend fun isActivitySeen(activityId: String): Boolean = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: return@background false + val activity = getActivityById(walletId = walletId, activityId = activityId) ?: return@background false return@background when (activity) { is Activity.Lightning -> activity.v1.seenAt != null is Activity.Onchain -> activity.v1.seenAt != null @@ -1398,7 +1425,7 @@ class ActivityService( } suspend fun markActivityAsSeen(activityId: String, seenAt: ULong? = null) = ServiceQueue.CORE.background { - val activity = getActivityById(activityId) ?: run { + val activity = getActivityById(walletId = walletId, activityId = activityId) ?: run { Logger.warn("Cannot mark activity as seen - activity not found: $activityId", context = TAG) return@background } @@ -1409,7 +1436,7 @@ class ActivityService( is Activity.Onchain -> Activity.Onchain(activity.v1.copy(seenAt = timestamp)) } - updateActivity(activityId, updatedActivity) + updateActivity(activityId = activityId, activity = updatedActivity) Logger.info("Marked activity $activityId as seen at $timestamp", context = TAG) } @@ -1426,6 +1453,7 @@ class ActivityService( suspend fun markAllUnseenActivitiesAsSeen() = ServiceQueue.CORE.background { val timestamp = (System.currentTimeMillis() / 1000).toULong() val activities = getActivities( + walletId = walletId, filter = ActivityFilter.ALL, txType = null, tags = null, diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index ba2b1ae635..3e66ef04df 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -54,6 +54,7 @@ import to.bitkit.models.PrimaryDisplay import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType +import to.bitkit.models.WalletScope import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.toSettingsString @@ -948,12 +949,20 @@ class MigrationService @Inject constructor( val onchain = activityRepo.getOnchainActivityByTxId(activityId) if (onchain != null) { applied++ - ActivityTags(activityId = onchain.id, tags = tagList) + ActivityTags( + walletId = WalletScope.default, + activityId = onchain.id, + tags = tagList, + ) } else { val activity = activityRepo.getActivity(activityId).getOrNull() if (activity != null) { applied++ - ActivityTags(activityId = activityId, tags = tagList) + ActivityTags( + walletId = WalletScope.default, + activityId = activityId, + tags = tagList, + ) } else { Logger.warn("Activity not found for tags: id=$activityId", context = TAG) null @@ -1005,6 +1014,7 @@ class MigrationService @Inject constructor( Activity.Lightning( LightningActivity( + walletId = WalletScope.default, id = item.id, txType = txType, status = status, @@ -1960,6 +1970,7 @@ class MigrationService @Inject constructor( val activityTimestamp = if (timestampSecs > 0u) timestampSecs else now val newOnchain = OnchainActivity( + walletId = WalletScope.default, id = item.id, txType = if (item.txType == "sent") PaymentType.SENT else PaymentType.RECEIVED, txId = txId, diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 023045080d..58d55d1034 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -97,24 +97,24 @@ class TrezorBridgeTransport( val session = json.decodeFromString(response).session openSessions[path] = session Logger.info("Opened Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) }.getOrElse { Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed") + TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed", errorCode = null) } } fun closeDevice(path: String): TrezorTransportWriteResult { val session = openSessions.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) return runCatching { post("/release/${encode(session)}") Logger.info("Closed Trezor Bridge device '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) }.getOrElse { Logger.warn("Failed to close Trezor Bridge device '$path'", it, context = TAG) - TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed") + TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge close failed", errorCode = null) } } @@ -123,6 +123,7 @@ class TrezorBridgeTransport( success = false, data = byteArrayOf(), error = "Trezor Bridge uses callMessage for '$path'", + errorCode = null, ) } @@ -130,6 +131,7 @@ class TrezorBridgeTransport( return TrezorTransportWriteResult( success = false, error = "Trezor Bridge uses callMessage for '$path' and ignored '${data.size}' bytes", + errorCode = null, ) } @@ -144,6 +146,7 @@ class TrezorBridgeTransport( messageType = 0u.toUShort(), data = byteArrayOf(), error = "Trezor Bridge device not open: $path", + errorCode = null, ) return runCatching { @@ -158,6 +161,7 @@ class TrezorBridgeTransport( messageType = 0u.toUShort(), data = byteArrayOf(), error = it.message ?: "Bridge call failed", + errorCode = null, ) } } @@ -189,6 +193,7 @@ class TrezorBridgeTransport( messageType = messageType, data = bytes.copyOfRange(HEADER_SIZE, HEADER_SIZE + length), error = "", + errorCode = null, ) } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index cba4965b59..6a7d2672eb 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -680,7 +680,7 @@ class TrezorTransport @Inject constructor( closeUsbDevice(path) val device = usbManager.deviceList[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) if (!usbManager.hasPermission(device)) { if (!requestUsbPermissionEnabled) { @@ -688,23 +688,25 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult( success = false, error = "USB permission missing for '$path'", + errorCode = null, ) } if (!requestUsbPermission(device)) { return TrezorTransportWriteResult( success = false, error = "USB permission denied for '$path'", + errorCode = null, ) } } val connection = usbManager.openDevice(device) - ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path", errorCode = null) val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") + return TrezorTransportWriteResult(success = false, error = "Failed to claim interface", errorCode = null) } val endpoints = findUsbEndpoints(usbInterface) @@ -714,6 +716,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult( success = false, error = "Could not find required endpoints", + errorCode = null, ) } @@ -724,10 +727,10 @@ class TrezorTransport @Inject constructor( endpoints.write, ) Logger.info("USB device opened: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB open failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -735,15 +738,15 @@ class TrezorTransport @Inject constructor( private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) openDevice.connection.releaseInterface(openDevice.usbInterface) openDevice.connection.close() Logger.info("USB device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -755,6 +758,7 @@ class TrezorTransport @Inject constructor( success = false, data = byteArrayOf(), error = "Device not open: $path", + errorCode = null, ) // Synchronous transfer on purpose: the async UsbRequest API requires @@ -773,14 +777,15 @@ class TrezorTransport @Inject constructor( success = false, data = byteArrayOf(), error = "USB read timed out", + errorCode = null, ) } Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "") + TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error", errorCode = null) } } @@ -788,7 +793,7 @@ class TrezorTransport @Inject constructor( private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) val bytesWritten = openDevice.connection.bulkTransfer( openDevice.writeEndpoint, @@ -797,14 +802,14 @@ class TrezorTransport @Inject constructor( WRITE_TIMEOUT_MS, ) if (bytesWritten != data.size) { - return TrezorTransportWriteResult(success = false, error = "USB write timed out") + return TrezorTransportWriteResult(success = false, error = "USB write timed out", errorCode = null) } Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error", errorCode = null) } } @@ -870,18 +875,18 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding", errorCode = null) } var bondAttempts = 0 while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") + return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected", errorCode = null) } } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + return TrezorTransportWriteResult(success = false, error = "Bonding timeout", errorCode = null) } Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { @@ -892,7 +897,7 @@ class TrezorTransport @Inject constructor( bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed") + return TrezorTransportWriteResult(success = false, error = "Bonding failed", errorCode = null) } } else { Logger.info("Device already bonded: '$address'", context = TAG) @@ -910,7 +915,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") } Logger.info("Reused open BLE device '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } val address = path.removePrefix("ble:") @@ -919,7 +924,7 @@ class TrezorTransport @Inject constructor( // fresh scan — a scan right after a disconnect often finds nothing yet. val device = discoveredBleDevices[address] ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path", errorCode = null) bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } @@ -940,13 +945,13 @@ class TrezorTransport @Inject constructor( if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Connection timeout") + return TrezorTransportWriteResult(success = false, error = "Connection timeout", errorCode = null) } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { disconnectBleDevice(path) - return TrezorTransportWriteResult(success = false, error = "Failed to connect") + return TrezorTransportWriteResult(success = false, error = "Failed to connect", errorCode = null) } gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) @@ -961,27 +966,27 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) Logger.info("BLE device opened: '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) connection.readQueue.clear() connection.writeLatch?.countDown() connection.connectionLatch?.countDown() Logger.info("Closed BLE device session '$path'", context = TAG) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = true, error = "") + ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) userInitiatedCloseSet.add(path) return try { @@ -1000,10 +1005,10 @@ class TrezorTransport @Inject constructor( connection.gatt.close() Thread.sleep(100) Logger.info("BLE device closed: '$path'", context = TAG) - TrezorTransportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty()) + TrezorTransportWriteResult(success = timeoutError == null, error = timeoutError.orEmpty(), errorCode = null) } catch (e: Exception) { Logger.error("BLE close failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed") + TrezorTransportWriteResult(success = false, error = e.message ?: "BLE close failed", errorCode = null) } finally { userInitiatedCloseSet.remove(path) } @@ -1015,7 +1020,8 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportReadResult( success = false, data = byteArrayOf(), - error = "Device not open: $path" + error = "Device not open: $path", + errorCode = null, ) return try { @@ -1023,14 +1029,15 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportReadResult( success = false, data = byteArrayOf(), - error = "Read timeout" + error = "Read timeout", + errorCode = null, ) Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") + TrezorTransportReadResult(success = true, data = data, error = "", errorCode = null) } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed", errorCode = null) } } @@ -1045,14 +1052,14 @@ class TrezorTransport @Inject constructor( @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] - ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path", errorCode = null) val writeChar = connection.writeCharacteristic - ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available", errorCode = null) if (!connection.isConnected) { Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) - return TrezorTransportWriteResult(success = false, error = "Device disconnected") + return TrezorTransportWriteResult(success = false, error = "Device disconnected", errorCode = null) } return try { @@ -1079,7 +1086,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { @@ -1092,7 +1099,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { @@ -1106,7 +1113,7 @@ class TrezorTransport @Inject constructor( Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue } - return TrezorTransportWriteResult(success = false, error = lastError) + return TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } Logger.debug("BLE wrote '${data.size}' bytes to '$path' (attempt '$attempt')", context = TAG) @@ -1114,13 +1121,13 @@ class TrezorTransport @Inject constructor( // Small delay between writes to avoid overwhelming the GATT Thread.sleep(BLE_WRITE_INTER_DELAY_MS) - return TrezorTransportWriteResult(success = true, error = "") + return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } - TrezorTransportWriteResult(success = false, error = lastError) + TrezorTransportWriteResult(success = false, error = lastError, errorCode = null) } catch (e: Exception) { Logger.error("BLE write failed", e, context = TAG) - TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed") + TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed", errorCode = null) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index 5de144a582..25544379df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -55,6 +55,10 @@ fun SpendingHwSignScreen( return } + LaunchedEffect(deviceId) { + viewModel.warmUpHardwareConnection(deviceId) + } + LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 6a0bdac66b..745d48b339 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -4,9 +4,11 @@ import com.synonym.bitkitcore.AccountAddresses import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AccountUtxo +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeAccount import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorAddressResponse @@ -19,6 +21,7 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import to.bitkit.ext.create import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice @@ -36,6 +39,7 @@ internal object TrezorPreviewData { minorVersion = 8u, patchVersion = 1u, pinProtection = true, + unlocked = true, passphraseProtection = false, initialized = true, needsBackup = false, @@ -51,6 +55,7 @@ internal object TrezorPreviewData { minorVersion = null, patchVersion = null, pinProtection = null, + unlocked = null, passphraseProtection = null, initialized = null, needsBackup = null, @@ -257,6 +262,7 @@ internal object TrezorPreviewData { sent = 0uL, net = 100_000L, fee = null, + feeRate = null, amount = 100_000uL, direction = TxDirection.RECEIVED, blockHeight = 849_990u, @@ -269,6 +275,7 @@ internal object TrezorPreviewData { sent = 50_000uL, net = -50_000L, fee = 1_200uL, + feeRate = 5.0, amount = 48_800uL, direction = TxDirection.SENT, blockHeight = 849_995u, @@ -281,6 +288,7 @@ internal object TrezorPreviewData { sent = 5_000uL, net = 0L, fee = 500uL, + feeRate = 2.0, amount = 500uL, direction = TxDirection.SELF_TRANSFER, blockHeight = null, @@ -305,6 +313,33 @@ internal object TrezorPreviewData { ), ) + val sampleWatcherActivities = listOf( + Activity.Onchain( + com.synonym.bitkitcore.OnchainActivity.create(walletId = "wallet0", + id = SAMPLE_TXID, + txType = PaymentType.RECEIVED, + txId = SAMPLE_TXID, + value = 100_000uL, + fee = 0uL, + address = SAMPLE_ADDRESS, + timestamp = 1_700_000_000uL, + confirmed = true, + ), + ), + Activity.Onchain( + com.synonym.bitkitcore.OnchainActivity.create(walletId = "wallet0", + id = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + txType = PaymentType.SENT, + txId = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + value = 48_800uL, + fee = 1_200uL, + address = SAMPLE_ADDRESS, + timestamp = 1_700_100_000uL, + confirmed = true, + ), + ), + ) + val uiStateWithActiveWatcher = TrezorUiState( network = TrezorNetworkState(selectedNetwork = BitkitCoreNetwork.REGTEST), watcher = TrezorWatcherState( @@ -312,7 +347,7 @@ internal object TrezorPreviewData { activeWatcherId = "watcher-abc-123", connectionStatus = WatcherConnectionStatus.CONNECTED, balance = sampleWalletBalance, - transactions = sampleHistoryTransactions.toImmutableList(), + activities = sampleWatcherActivities.toImmutableList(), transactionCount = 2u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, 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 b4772d9785..6c9f0f831e 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 @@ -6,10 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult import com.synonym.bitkitcore.TrezorScriptType @@ -31,6 +31,7 @@ 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 @@ -68,7 +69,7 @@ class TrezorViewModel @Inject constructor( it.copy( watcher = it.watcher.copy( balance = event.balance, - transactions = event.transactions.toImmutableList(), + activities = event.activities.toImmutableList(), transactionCount = event.txCount, blockHeight = event.blockHeight, accountType = event.accountType, @@ -712,12 +713,15 @@ class TrezorViewModel @Inject constructor( ) ) } + val walletId = runCatching { HwWalletId.derive(mapOf("watcher" to key)) } + .getOrDefault("trezor:watcher") val result = trezorRepo.startWatcher( watcherId = watcherId, extendedKey = key, network = state.selectedNetwork, gapLimit = gapLimit, accountType = state.watcher.selectedAccountType, + walletId = walletId, ) if (result.isSuccess) { @@ -776,7 +780,7 @@ class TrezorViewModel @Inject constructor( activeWatcherId = null, connectionStatus = WatcherConnectionStatus.IDLE, balance = null, - transactions = persistentListOf(), + activities = persistentListOf(), transactionCount = 0u, blockHeight = 0u, accountType = null, @@ -956,8 +960,8 @@ data class TrezorUiState( val watcherBalance: WalletBalance? get() = watcher.balance - val watcherTransactions: ImmutableList - get() = watcher.transactions + val watcherActivities: ImmutableList + get() = watcher.activities val watcherTransactionCount: UInt get() = watcher.transactionCount @@ -1035,7 +1039,7 @@ data class TrezorWatcherState( val activeWatcherId: String? = null, val connectionStatus: WatcherConnectionStatus = WatcherConnectionStatus.IDLE, val balance: WalletBalance? = null, - val transactions: ImmutableList = persistentListOf(), + val activities: ImmutableList = persistentListOf(), val transactionCount: UInt = 0u, val blockHeight: UInt = 0u, val accountType: AccountType? = null, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt index aeaabafc89..e0f1b9a266 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/WatcherSection.kt @@ -24,7 +24,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.TxDirection +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.PaymentType import to.bitkit.models.safe import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize @@ -171,26 +172,25 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { } } - if (uiState.watcherTransactions.isNotEmpty()) { + if (uiState.watcherActivities.isNotEmpty()) { VerticalSpacer(12.dp) Caption13Up( - text = "Transactions (${uiState.watcherTransactions.size})", + text = "Activities (${uiState.watcherActivities.size})", color = Colors.White64, ) VerticalSpacer(4.dp) LazyColumn( modifier = Modifier.heightIn(max = 200.dp), ) { - items(uiState.watcherTransactions) { tx -> - val directionLabel = when (tx.direction) { - TxDirection.SENT -> "Sent" - TxDirection.RECEIVED -> "Recv" - TxDirection.SELF_TRANSFER -> "Self" + items(uiState.watcherActivities.filterIsInstance()) { activity -> + val onchain = activity.v1 + val directionLabel = when (onchain.txType) { + PaymentType.SENT -> "Sent" + PaymentType.RECEIVED -> "Recv" } - val directionColor = when (tx.direction) { - TxDirection.SENT -> Colors.Red - TxDirection.RECEIVED -> Colors.Green - TxDirection.SELF_TRANSFER -> Colors.White64 + val directionColor = when (onchain.txType) { + PaymentType.SENT -> Colors.Red + PaymentType.RECEIVED -> Colors.Green } Row( modifier = Modifier @@ -199,17 +199,17 @@ private fun WatcherStatusContent(uiState: TrezorUiState) { horizontalArrangement = Arrangement.SpaceBetween, ) { Caption( - text = "$directionLabel ${tx.amount} sats", + text = "$directionLabel ${onchain.value} sats", color = directionColor, ) HorizontalSpacer(8.dp) Caption( - text = "${tx.txid.take(8)}...${tx.txid.takeLast(8)}", + text = "${onchain.txId.take(8)}...${onchain.txId.takeLast(8)}", color = Colors.White50, ) HorizontalSpacer(8.dp) Caption( - text = "${tx.confirmations} conf", + text = if (onchain.confirmed) "confirmed" else "pending", color = Colors.White50, ) } diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt index 4ce86166b4..6a69b9b4c0 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.shared.toast import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.models.Toast object ToastEventBus { @@ -23,11 +24,12 @@ object ToastEventBus { } suspend fun send(error: Throwable) { + if (error.isTrezorUserCancellation()) return _events.emit( Toast( type = Toast.ToastType.ERROR, title = "Error", - description = error.message ?: "Unknown error", + description = error.message?.takeIf { it.isNotBlank() } ?: "Unknown error", autoHide = true, visibilityTime = Toast.VISIBILITY_TIME_DEFAULT, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index aa0d1ba2bf..dd7217c838 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -85,6 +85,7 @@ import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.isFixedAmount +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat @@ -2876,10 +2877,12 @@ class AppViewModel @Inject constructor( } fun toast(error: Throwable) { + if (error.isTrezorUserCancellation()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = error.message ?: context.getString(R.string.common__error_body) + description = error.message?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.common__error_body), ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 430d2885f2..884a0cb101 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -33,6 +33,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose +import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast @@ -523,6 +524,10 @@ class TransferViewModel @Inject constructor( } /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ + fun warmUpHardwareConnection(deviceId: String) { + hwWalletRepo.warmUpKnownDevice(deviceId) + } + fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { if (hwTransferSignJob?.isActive == true) return @@ -583,6 +588,7 @@ class TransferViewModel @Inject constructor( } }.getOrElse { if (it is CancellationException && it !is TimeoutCancellationException) throw it + if (it.isTrezorUserCancellation()) throw it throw HardwareReconnectError(it) } } @@ -633,8 +639,12 @@ class TransferViewModel @Inject constructor( private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { when (e) { is HardwareReconnectError -> { + if (e.isTrezorUserCancellation()) { + Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) + return + } Logger.error("Failed to reconnect hardware device", e, context = TAG) - showHardwareReconnectError() + showHardwareReconnectError(deviceId) } is HardwareSigningTimeoutError -> { Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) @@ -645,13 +655,24 @@ class TransferViewModel @Inject constructor( showHardwareFundingError(e) } else -> { + if (e.isTrezorUserCancellation()) { + Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) + return + } Logger.error("Hardware transfer failed", e, context = TAG) ToastEventBus.send(e) } } } - private suspend fun showHardwareReconnectError() { + private suspend fun showHardwareReconnectError(deviceId: String) { + if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.hardware__connect_error), + ) + return + } ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), diff --git a/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt b/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt new file mode 100644 index 0000000000..b3816a1977 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/BackupRestoreCompatTest.kt @@ -0,0 +1,96 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import to.bitkit.data.AppCacheData +import to.bitkit.di.json +import to.bitkit.models.ActivityBackupV1 +import to.bitkit.models.MetadataBackupV1 +import to.bitkit.models.WalletScope +import to.bitkit.test.BaseUnitTest +import org.junit.Test +import kotlin.test.assertEquals + +class BackupRestoreCompatTest : BaseUnitTest() { + + @Test + fun `decodeActivityBackupV1Compat fills missing walletId`() { + val payload = ActivityBackupV1( + createdAt = 1_000, + activities = listOf( + Activity.Onchain( + OnchainActivity.create( + id = "tx1", + txType = PaymentType.RECEIVED, + txId = "tx1", + value = 100uL, + fee = 0uL, + address = "", + timestamp = 1_700_000_000uL, + ) + ) + ), + activityTags = listOf( + ActivityTags( + walletId = WalletScope.default, + activityId = "tx1", + tags = listOf("tag"), + ) + ), + closedChannels = emptyList(), + ) + val legacyJson = json.encodeToString(payload) + .let { json.parseToJsonElement(it).withoutWalletIds() } + .let { json.encodeToString(it) } + + val parsed = legacyJson.decodeActivityBackupV1Compat() + + assertEquals(WalletScope.default, (parsed.activities.single() as Activity.Onchain).v1.walletId) + assertEquals(WalletScope.default, parsed.activityTags.single().walletId) + } + + @Test + fun `decodeMetadataBackupV1Compat fills missing walletId`() { + val payload = MetadataBackupV1( + createdAt = 1_000, + tagMetadata = listOf( + PreActivityMetadata( + walletId = WalletScope.default, + paymentId = "payment1", + tags = listOf("tag"), + paymentHash = null, + txId = null, + address = null, + isReceive = true, + feeRate = 1uL, + isTransfer = false, + channelId = null, + createdAt = 1_700_000_000uL, + ) + ), + cache = AppCacheData(), + ) + val legacyJson = json.encodeToString(payload) + .let { json.parseToJsonElement(it).withoutWalletIds() } + .let { json.encodeToString(it) } + + val parsed = legacyJson.decodeMetadataBackupV1Compat() + + assertEquals(WalletScope.default, parsed.tagMetadata.single().walletId) + } + + private fun JsonElement.withoutWalletIds(): JsonElement = when (this) { + is JsonObject -> JsonObject( + filterKeys { it != "walletId" } + .mapValues { (_, value) -> value.withoutWalletIds() } + ) + is JsonArray -> JsonArray(map { it.withoutWalletIds() }) + else -> this + } +} diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt new file mode 100644 index 0000000000..b0afdb27a6 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorException +import to.bitkit.utils.AppError +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class TrezorExceptionExtTest { + @Test + fun `isTrezorUserCancellation returns true for user cancelled exceptions`() { + assertTrue(TrezorException.UserCancelled().isTrezorUserCancellation()) + assertTrue(TrezorException.PinCancelled().isTrezorUserCancellation()) + assertTrue(TrezorException.PassphraseCancelled().isTrezorUserCancellation()) + } + + @Test + fun `isTrezorUserCancellation walks the cause chain`() { + val error = AppError(TrezorException.UserCancelled()) + + assertTrue(error.isTrezorUserCancellation()) + } + + @Test + fun `isTrezorUserCancellation returns false for other errors`() { + assertFalse(AppError("sign failed").isTrezorUserCancellation()) + assertFalse(TrezorException.Timeout().isTrezorUserCancellation()) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index d65386de54..caf7b134f4 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -67,7 +67,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { @Test fun `loadActivity falls back to hardware wallet activity when missing from the database`() = test { val hwActivity = Activity.Onchain( - OnchainActivity.create( + OnchainActivity.create(walletId = "wallet0", id = ACTIVITY_ID, txType = PaymentType.RECEIVED, txId = ACTIVITY_ID, @@ -263,7 +263,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { confirmed: Boolean = false, ): Activity.Onchain { return Activity.Onchain( - v1 = OnchainActivity.create( + v1 = OnchainActivity.create(walletId = "wallet0", 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 286fcdd677..e811196278 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -63,7 +63,7 @@ class ActivityRepoTest : BaseUnitTest() { on { v1 } doReturn testActivityV1 } - private val baseOnchainActivity = OnchainActivity.create( + private val baseOnchainActivity = OnchainActivity.create(walletId = "wallet0", id = "base_activity_id", txType = PaymentType.SENT, txId = "base_tx_id", @@ -279,7 +279,7 @@ class ActivityRepoTest : BaseUnitTest() { boostTxIds = listOf("boost-txid"), contact = "contact", ).v1 - val watcher = OnchainActivity.create( + val watcher = OnchainActivity.create(walletId = "wallet0", id = "transfer-txid", txType = PaymentType.SENT, txId = "transfer-txid", @@ -313,7 +313,7 @@ class ActivityRepoTest : BaseUnitTest() { @Test fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { - val watcher = OnchainActivity.create( + val watcher = OnchainActivity.create(walletId = "wallet0", id = "hardware-only-txid", txType = PaymentType.RECEIVED, txId = "hardware-only-txid", diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 50cf626b1d..a1c00adaff 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -3,11 +3,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult -import com.synonym.bitkitcore.HistoryTransaction +import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -22,6 +22,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -38,6 +39,7 @@ 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 @@ -83,6 +85,10 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + whenever(trezorRepo.deriveWalletId(any(), any())).thenAnswer { invocation -> + val xpubs = invocation.getArgument>(0) + "derived-${xpubs.values.sorted().joinToString()}" + } runBlocking { whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) } @@ -145,7 +151,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 10_562_411uL), - transactions = listOf(receivedTransaction(amount = 10_562_411uL)), + activities = listOf(watcherActivity(amount = 10_562_411uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, @@ -168,7 +175,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -177,7 +184,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -197,7 +204,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -206,7 +214,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -234,7 +243,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -243,7 +253,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -257,22 +268,57 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `preserves generated timestamp for pending tx refreshes`() = test { - whenever(clock.now()) - .thenReturn(Instant.fromEpochSeconds(1_800_000_000)) - .thenReturn(Instant.fromEpochSeconds(1_800_000_060)) + fun `merges duplicate sent tx activities without subtracting fee twice`() = test { val sut = createRepo() - val pendingTx = receivedTransaction(amount = 100uL).copy( + val fee = 1_000uL + + watcherEvents.emit( + "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 0uL), + activities = listOf( + watcherActivity(amount = 40_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), + ), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.NATIVE_SEGWIT, + ) + ) + watcherEvents.emit( + "dev1|taproot" to WatcherEvent.TransactionsChanged( + balance = walletBalance(total = 0uL), + activities = listOf( + watcherActivity(amount = 20_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), + ), + transactionDetails = emptyList(), + txCount = 1u, + blockHeight = 1u, + accountType = AccountType.TAPROOT, + ) + ) + + val activity = sut.wallets.value.single().activities.single() as Activity.Onchain + assertEquals(PaymentType.SENT, activity.v1.txType) + assertEquals(60_000uL, activity.v1.value) + assertEquals(fee, activity.v1.fee) + } + + @Test + fun `preserves activity timestamp across watcher refreshes`() = test { + val sut = createRepo() + val pendingActivity = watcherActivity( + amount = 100uL, txid = "pending", blockHeight = null, - timestamp = null, + timestamp = 1_800_000_000uL, confirmations = 0u, ) watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), + activities = listOf(pendingActivity), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -283,7 +329,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(pendingTx), + activities = listOf(pendingActivity), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -296,7 +343,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `starts watchers only for the address types the user monitors`() = test { + fun `starts native segwit watcher regardless of monitored address types`() = test { storeData.value = HwWalletData( knownDevices = listOf( device.copy( @@ -313,9 +360,9 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, never()).startWatcher(eq("dev1|taproot"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, never()).startWatcher(eq("dev1|legacy"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -333,6 +380,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = eq(electrumServer), + walletId = any(), ) } @@ -358,6 +406,62 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = eq(secondServer), + walletId = any(), + ) + } + + @Test + fun `restarts active watchers when wallet id changes`() = test { + val derivedWalletId = "derived-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)) + + createRepo() + runCurrent() + + val order = inOrder(trezorRepo) + order.verify(trezorRepo).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + extendedKey = eq("zpubNS"), + network = eq(Env.network.toCoreNetwork()), + gapLimit = any(), + accountType = anyOrNull(), + electrumUrl = any(), + walletId = eq("legacy-wallet-id"), + ) + + storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = derivedWalletId))) + runCurrent() + + order.verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + order.verify(trezorRepo).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + extendedKey = eq("zpubNS"), + network = eq(Env.network.toCoreNetwork()), + gapLimit = any(), + accountType = anyOrNull(), + electrumUrl = any(), + walletId = eq(derivedWalletId), + ) + } + + @Test + fun `starts watchers with derived wallet id when store value is blank`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = ""))) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + + createRepo() + runCurrent() + + verify(trezorRepo).startWatcher( + watcherId = eq("dev1|nativeSegwit"), + extendedKey = eq("zpubNS"), + network = eq(Env.network.toCoreNetwork()), + gapLimit = any(), + accountType = anyOrNull(), + electrumUrl = any(), + walletId = eq("derived-zpubNS"), ) } @@ -367,12 +471,12 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -385,7 +489,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL)), + activities = listOf(watcherActivity(amount = 100uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -397,10 +502,11 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + activities = listOf( + watcherActivity(amount = 100uL), + watcherActivity(amount = 50uL, txid = "t2"), ), + transactionDetails = emptyList(), txCount = 2u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -412,10 +518,11 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), - transactions = listOf( - receivedTransaction(amount = 100uL), - receivedTransaction(amount = 50uL).copy(txid = "t2"), + activities = listOf( + watcherActivity(amount = 100uL), + watcherActivity(amount = 50uL, txid = "t2"), ), + transactionDetails = emptyList(), txCount = 2u, blockHeight = 3u, accountType = AccountType.NATIVE_SEGWIT, @@ -435,7 +542,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -444,7 +551,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, @@ -454,7 +561,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = listOf(receivedTransaction(amount = 100uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -463,7 +571,8 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), - transactions = listOf(receivedTransaction(amount = 50uL).copy(txid = "shared")), + activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.TAPROOT, @@ -483,7 +592,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -492,9 +601,10 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 40uL), - transactions = listOf( - receivedTransaction(amount = 60uL).copy(txid = "t3", direction = TxDirection.SENT), + activities = listOf( + watcherActivity(amount = 60uL, txid = "t3", txType = PaymentType.SENT), ), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, @@ -514,13 +624,14 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) - verify(trezorRepo, never()).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any()) + 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()) watcherEvents.emit( "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 421_900uL), - transactions = listOf(receivedTransaction(amount = 421_900uL)), + activities = listOf(watcherActivity(amount = 421_900uL)), + transactionDetails = emptyList(), txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -558,29 +669,41 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `keeps a stale watcher until stopping it succeeds`() = test { storeData.value = HwWalletData( - knownDevices = listOf(device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS"))) + knownDevices = listOf( + device.copy(xpubs = mapOf("nativeSegwit" to "zpubNS", "taproot" to "zpubTR")), + ) ) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.failure(AppError("stop failed"))) val sut = createRepo() + runCurrent() watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, ) ) + runCurrent() - // Stop fails: the watcher data must survive so the balance is not silently wrong. - settingsData.value = SettingsData(addressTypesToMonitor = emptyList()) + // 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)) - settingsData.value = SettingsData(addressTypesToMonitor = listOf("taproot")) + storeData.value = HwWalletData( + knownDevices = listOf( + device.copy(xpubs = mapOf("taproot" to "zpubTR"), lastConnectedAt = 2L), + ), + ) + runCurrent() assertEquals(0uL, sut.totalSats.value) } @@ -596,7 +719,7 @@ class HwWalletRepoTest : BaseUnitTest() { watcherEvents.emit( "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), - transactions = emptyList(), + activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, @@ -703,6 +826,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = any(), + walletId = any(), ) } @@ -724,6 +848,15 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).onAppForegrounded() } + @Test + fun `forwards warm up known device to the trezor repo`() = test { + val sut = createRepo() + + sut.warmUpKnownDevice("dev1") + + verify(trezorRepo).warmUpKnownDevice("dev1") + } + @Test fun `composeFundingTransaction returns composed fee data`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) @@ -836,6 +969,26 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).broadcastRawTx(any()) } + @Test + fun `signAndBroadcastFunding keeps session when user cancels on device`() = test { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) + .thenReturn(Result.failure(TrezorException.UserCancelled())) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding("dev1", funding) + + assertEquals(true, result.isFailure) + verify(trezorRepo, never()).disconnectStaleSession(any()) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + @Test fun `forwards pairing code calls to the trezor repo`() = test { val sut = createRepo() @@ -863,6 +1016,7 @@ class HwWalletRepoTest : BaseUnitTest() { gapLimit = any(), accountType = anyOrNull(), electrumUrl = any(), + walletId = any(), ) } @@ -875,19 +1029,29 @@ class HwWalletRepoTest : BaseUnitTest() { total = total, ) - private fun receivedTransaction(amount: ULong) = HistoryTransaction( - txid = "t1", - received = amount, - sent = 0uL, - net = amount.toLong(), - fee = null, - amount = amount, - direction = TxDirection.RECEIVED, - blockHeight = 850_000u, - timestamp = 1_700_000_000uL, - confirmations = 3u, + 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, + ) = Activity.Onchain( + OnchainActivity.create( + walletId = "wallet0", + id = txid, + txType = txType, + txId = txid, + value = amount, + fee = fee, + address = "", + timestamp = timestamp ?: 0uL, + confirmed = blockHeight != null && confirmations > 0u, + ) ) + @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) @@ -974,6 +1138,7 @@ class HwWalletRepoTest : BaseUnitTest() { any(), anyOrNull(), any(), + any(), ) ) } diff --git a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt index 139405fab3..d1c8409d49 100644 --- a/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PreActivityMetadataRepoTest.kt @@ -45,7 +45,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { isReceive = false, feeRate = 10u, isTransfer = false, - channelId = "channel-123" + channelId = "channel-123", + walletId = "wallet0", ) @Before @@ -375,7 +376,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -393,7 +395,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = false, tags = emptyList(), - isTransfer = true + isTransfer = true, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -413,7 +416,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { tags = listOf("important"), feeRate = 10u, isTransfer = true, - channelId = "channel-123" + channelId = "channel-123", + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -430,7 +434,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = emptyList(), - isTransfer = false + isTransfer = false, + walletId = "wallet0", ) assertTrue(result.isFailure) @@ -455,7 +460,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - feeRate = null + feeRate = null, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -478,7 +484,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { address = address, isReceive = true, tags = tags, - channelId = null + channelId = null, + walletId = "wallet0", ) assertTrue(result.isSuccess) @@ -496,7 +503,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) assertTrue(result.isFailure) @@ -592,7 +600,8 @@ class PreActivityMetadataRepoTest : BaseUnitTest() { id = id, address = address, isReceive = true, - tags = tags + tags = tags, + walletId = "wallet0", ) 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 039f52c708..f69cf75225 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -291,7 +291,7 @@ class TransferRepoTest : BaseUnitTest() { fundingTxo = fundingTxo, isChannelReady = false, ) - val activity = OnchainActivity.create( + val activity = OnchainActivity.create(walletId = "wallet0", id = fundingTxo.txid, txType = PaymentType.SENT, txId = fundingTxo.txid, @@ -609,7 +609,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create( + val sweepActivity = OnchainActivity.create(walletId = "wallet0", id = "sweep-activity-id", txType = PaymentType.RECEIVED, txId = "sweep-txid", @@ -741,7 +741,7 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) - val sweepActivity = OnchainActivity.create( + val sweepActivity = OnchainActivity.create(walletId = "wallet0", 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 9c0dc0f612..8820930a4b 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorSignedMessageResponse @@ -25,6 +26,7 @@ import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -95,7 +97,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) whenever(trezorTransport.disconnectDevice(any())).thenReturn( - TrezorTransportWriteResult(success = true, error = "") + TrezorTransportWriteResult(success = true, error = "", errorCode = null) ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) @@ -467,6 +469,75 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).scan() } + @Test + fun `warmUpKnownDevice connects to the requested bluetooth device`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + val device = mockDeviceInfo( + id = bleDeviceId, + path = bleDeviceId, + transportType = TrezorTransportType.BLUETOOTH, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + sut.warmUpKnownDevice(bleDeviceId) + advanceUntilIdle() + + assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) + verify(trezorService).connect(eq(bleDeviceId), any()) + } + + @Test + fun `warmUpKnownDevice skips non-bluetooth devices`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(mockKnownDevice())) + sut = createSut() + + sut.initialize() + sut.warmUpKnownDevice(DEVICE_ID) + advanceUntilIdle() + + verify(trezorService, never()).scan() + } + + @Test + fun `warmUpKnownDevice skips when device is already connected`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + val device = mockDeviceInfo( + id = bleDeviceId, + path = bleDeviceId, + transportType = TrezorTransportType.BLUETOOTH, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false, true) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) + sut = createSut() + sut.initialize() + sut.warmUpKnownDevice(bleDeviceId) + advanceUntilIdle() + + sut.warmUpKnownDevice(bleDeviceId) + advanceUntilIdle() + + verify(trezorService, times(1)).scan() + } + @Test fun `onTransportRestored skips usb device without permission`() = test { val device = mockDeviceInfo() @@ -1124,6 +1195,7 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(features, result.getOrNull()) assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) verify(trezorService).connect(eq(bleDeviceId), any()) + verify(trezorService).scan() } @Test @@ -1142,6 +1214,51 @@ class TrezorRepoTest : BaseUnitTest() { assertNull(sut.state.value.error) } + @Test + fun `disconnectStaleSession should not disconnect unrelated connected device`() = test { + val otherDeviceId = "device-other" + val knownOther = mockKnownDevice(id = otherDeviceId, path = "/other") + val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other") + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownOther)) + whenever(trezorService.scan()).thenReturn(listOf(otherDevice)) + whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + assertTrue(sut.connectKnownDevice(otherDeviceId).isSuccess) + assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) + + val result = sut.disconnectStaleSession(DEVICE_ID) + + assertTrue(result.isSuccess) + assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) + verify(trezorService, never()).disconnect() + } + + @Test + fun `connectKnownDevice failure should not disconnect unrelated connected device`() = test { + val otherDeviceId = "device-other" + val knownOther = mockKnownDevice(id = otherDeviceId, path = "/other") + val knownTarget = mockKnownDevice() + val otherDevice = mockDeviceInfo(id = otherDeviceId, path = "/other") + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownTarget, knownOther)) + whenever(trezorService.scan()).thenReturn(listOf(otherDevice)) + whenever(trezorService.connect(eq(otherDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + assertTrue(sut.connectKnownDevice(otherDeviceId).isSuccess) + + whenever(trezorService.scan()).thenReturn(emptyList()) + val result = sut.connectKnownDevice(DEVICE_ID) + + assertTrue(result.isFailure) + assertEquals(otherDeviceId, sut.state.value.connectedDeviceId()) + verify(trezorService, never()).disconnect() + } + @Test fun `ensureConnected returns current selected device without reconnecting`() = test { val features = mockFeatures() @@ -1163,6 +1280,58 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).disconnect() } + @Test + fun `ensureConnected retries bluetooth reconnect until scan finds the device`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + val device = mockDeviceInfo( + id = bleDeviceId, + path = bleDeviceId, + transportType = TrezorTransportType.BLUETOOTH, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(emptyList(), emptyList(), listOf(device)) + whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.ensureConnected(bleDeviceId) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + verify(trezorService, times(3)).scan() + verify(trezorService).connect(eq(bleDeviceId), any()) + verify(trezorService, never()).connect(eq(bleDeviceId), any(), eq(false)) + } + + @Test + fun `ensureConnected stops bluetooth retry after user cancellation`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(emptyList()) + whenever(trezorService.connect(eq(bleDeviceId), any())).doAnswer { throw TrezorException.UserCancelled() } + sut = createSut() + + sut.initialize() + val result = sut.ensureConnected(bleDeviceId) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is TrezorException.UserCancelled) + verify(trezorService, times(1)).connect(eq(bleDeviceId), any()) + } + // endregion // region clearError diff --git a/app/src/test/java/to/bitkit/test/BaseUnitTest.kt b/app/src/test/java/to/bitkit/test/BaseUnitTest.kt index ef07858b03..8b6650d871 100644 --- a/app/src/test/java/to/bitkit/test/BaseUnitTest.kt +++ b/app/src/test/java/to/bitkit/test/BaseUnitTest.kt @@ -5,7 +5,10 @@ import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before import org.junit.Rule +import to.bitkit.models.WalletScope @OptIn(ExperimentalCoroutinesApi::class) abstract class BaseUnitTest( @@ -16,5 +19,15 @@ abstract class BaseUnitTest( protected val testDispatcher get() = coroutinesTestRule.testDispatcher + @Before + fun setUpWalletScope() { + WalletScope.testOverride = "wallet0" + } + + @After + fun tearDownWalletScope() { + WalletScope.testOverride = null + } + protected fun test(block: suspend TestScope.() -> Unit) = runTest(testDispatcher) { block() } } 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 66d1a9d944..fa1bd073a7 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 @@ -361,7 +361,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `startWatcher should not expose active watcher until start completes`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") @@ -391,13 +391,13 @@ class TrezorViewModelTest : BaseUnitTest() { sut.startWatcher() advanceUntilIdle() - verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any()) + verify(trezorRepo, never()).startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any()) assertNull(sut.uiState.value.activeWatcherId) } @Test fun `watcher transaction event should mark watcher connected`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -406,7 +406,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = TrezorPreviewData.sampleWatcherActivities, + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -424,7 +425,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `watcher event should be handled while start is in flight`() = test { val startResult = CompletableDeferred>() - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { startResult.await() } sut.setWatcherExtendedKey("xpub6test123") sut.startWatcher() @@ -433,7 +434,8 @@ class TrezorViewModelTest : BaseUnitTest() { watcherEventsFlow.emit( watcherId to WatcherEvent.TransactionsChanged( - transactions = TrezorPreviewData.sampleHistoryTransactions, + activities = TrezorPreviewData.sampleWatcherActivities, + transactionDetails = emptyList(), balance = TrezorPreviewData.sampleWalletBalance, txCount = 3u, blockHeight = 850_000u, @@ -458,7 +460,7 @@ class TrezorViewModelTest : BaseUnitTest() { @Test fun `stopWatcher should stop repo watcher and clear watcher state`() = test { - whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any())) + whenever(trezorRepo.startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any())) .thenReturn(Result.success(Unit)) whenever(trezorRepo.stopWatcher(any())).thenReturn(Result.success(Unit)) sut.setWatcherExtendedKey("xpub6test123") @@ -474,7 +476,7 @@ class TrezorViewModelTest : BaseUnitTest() { assertNull(state.activeWatcherId) assertEquals(WatcherConnectionStatus.IDLE, state.watcherConnectionStatus) assertNull(state.watcherBalance) - assertTrue(state.watcherTransactions.isEmpty()) + assertTrue(state.watcherActivities.isEmpty()) } @Test diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index 8b9e64ff12..59f38021c8 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -57,7 +57,7 @@ class BoostTransactionViewModelTest : BaseUnitTest() { private val totalFee = 1000UL private val testValue = 50000UL - private val onchainActivity = OnchainActivity.create( + private val onchainActivity = OnchainActivity.create(walletId = "wallet0", id = "test_id", txType = PaymentType.SENT, txId = mockTxId, diff --git a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt index 526b3825ae..57e7fbb6f0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/ActivityListViewModelTest.kt @@ -121,7 +121,7 @@ class ActivityListViewModelTest : BaseUnitTest() { } private fun onchainActivity(id: String, txType: PaymentType, timestamp: ULong) = Activity.Onchain( - OnchainActivity.create( + OnchainActivity.create(walletId = "wallet0", id = id, txType = txType, txId = id, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 54f54d56f5..f0087e1531 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -5,12 +5,14 @@ import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.withTimeout import org.junit.Before @@ -24,6 +26,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -34,6 +37,7 @@ import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet +import to.bitkit.models.Toast import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.models.safe @@ -46,9 +50,11 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -296,6 +302,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(RuntimeException("no device"))) + whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -332,6 +339,52 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm does not fund order when user cancels on device`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())) + .thenReturn(Result.failure(TrezorException.UserCancelled())) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm does not toast when user cancels during reconnect`() = test { + val order = previewBtOrder() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + 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") + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertTrue(toasts.isEmpty()) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + } + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", diff --git a/changelog.d/next/1062.added.md b/changelog.d/next/1062.added.md new file mode 100644 index 0000000000..1994dfee1d --- /dev/null +++ b/changelog.d/next/1062.added.md @@ -0,0 +1 @@ +Trezor hardware wallet support with improved connection reliability, native SegWit balance tracking, and transfer to spending. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a80d35b4d6..e939233749 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.75" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.3.9" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }