From b537530b6234dace9b4a5780b12269b1720d9636 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 11:28:25 -0300 Subject: [PATCH 01/31] feat: identity data model + walletId keying --- .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../main/java/to/bitkit/models/KnownDevice.kt | 7 + .../to/bitkit/repositories/HwWalletRepo.kt | 172 +++++++++------ .../java/to/bitkit/repositories/TrezorRepo.kt | 108 +++++++--- app/src/main/java/to/bitkit/ui/ContentView.kt | 36 ++-- .../hardware/SpendingAmountHwScreen.kt | 6 +- .../transfer/hardware/SpendingHwSignScreen.kt | 10 +- .../screens/wallets/HardwareWalletScreen.kt | 6 +- .../ui/screens/wallets/HwWalletViewModel.kt | 8 +- .../ui/sheets/hardware/HwConnectViewModel.kt | 11 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 95 ++++---- .../bitkit/repositories/HwWalletRepoTest.kt | 204 ++++++++++-------- .../to/bitkit/repositories/TrezorRepoTest.kt | 8 +- .../sheets/hardware/HwConnectViewModelTest.kt | 16 +- .../viewmodels/TransferViewModelTest.kt | 182 ++++++++-------- 15 files changed, 497 insertions(+), 373 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index 3b8e93490..e0a068939 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -22,6 +22,7 @@ data class HwWallet( val activities: ImmutableList, val fundingBalanceSats: ULong = balanceSats, val deviceIds: ImmutableSet = persistentSetOf(id), + val passphraseProtected: Boolean = false, ) /** Serializable per-device balance snapshot carried by [BalanceState]. */ diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt index 6b71f0f0e..c6f7826dc 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -18,4 +18,11 @@ data class KnownDevice( /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ val customLabel: String? = null, val walletId: String = "", + /** + * Whether this entry is a passphrase (hidden) wallet. Nothing else in the record can tell one + * apart from the standard wallet: the xpubs are opaque and the selected mode only lives in + * memory, so reconnects would silently fall back to the standard wallet without this. The + * passphrase itself is never persisted. + */ + val passphraseProtected: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 68c280e44..4806568a6 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -114,7 +114,28 @@ class HwWalletRepo @Inject constructor( fun onAppForegrounded() = trezorRepo.onAppForegrounded() - fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId) + fun warmUpKnownDevice(walletId: String) { + scope.launch { + transportDeviceIdOrNull(walletId)?.let { trezorRepo.warmUpKnownDevice(it) } + } + } + + /** + * Entries tracking one wallet identity. A physical device holds the standard wallet plus one + * entry per passphrase wallet, and each of those is stored once per transport it paired over. + */ + private suspend fun devicesForWallet(walletId: String): List = + hwWalletStore.loadKnownDevices().filter { it.resolvedWalletId() == walletId } + + /** Transport-level id to reach [walletId] with: the connected entry, else the most recent one. */ + private suspend fun transportDeviceIdOrNull(walletId: String): String? { + val devices = devicesForWallet(walletId) + val connectedId = trezorRepo.state.value.connectedDeviceId() + return devices.find { it.id == connectedId }?.id ?: devices.maxByOrNull { it.lastConnectedAt }?.id + } + + private suspend fun transportDeviceId(walletId: String): String = + requireNotNull(transportDeviceIdOrNull(walletId)) { "Unknown hardware wallet '$walletId'" } suspend fun resetState() = withContext(ioDispatcher) { watcherMutex.withLock { @@ -163,33 +184,42 @@ class HwWalletRepo @Inject constructor( return trezorRepo.connect(deviceId) } - /** Reconnects a known paired device so its session is live for on-device signing. */ + /** Reconnects a known paired wallet so its session is live for on-device signing. */ suspend fun reconnect( - deviceId: String, + walletId: String, forceSession: Boolean = false, - ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + trezorRepo.connectKnownDevice(transportDeviceId(walletId), forceSession = forceSession).getOrThrow() + } + } - suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + suspend fun ensureConnected(walletId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + trezorRepo.ensureConnected(transportDeviceId(walletId)).getOrThrow() + } + } - suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(deviceId) + suspend fun isKnownBluetoothDevice(walletId: String): Boolean = withContext(ioDispatcher) { + val deviceId = transportDeviceIdOrNull(walletId) ?: return@withContext false + trezorRepo.isKnownBluetoothDevice(deviceId) + } suspend fun getFundingAccount( - deviceId: String, + walletId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val devices = hwWalletStore.loadKnownDevices() - val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } - val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + val devices = devicesForWallet(walletId) + val target = requireNotNull(devices.firstOrNull { it.xpubs.containsKey(addressType.settingsKey) }) { + "Hardware wallet '$walletId' has no '${addressType.settingsKey}' account" + } val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { - "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" + "Hardware wallet '$walletId' has no '${addressType.settingsKey}' account" } val balanceSats = _watcherData.value .values - .filter { - it.addressType == addressType.settingsKey && - it.deviceId in groupIds - } + .filter { it.addressType == addressType.settingsKey && it.walletId == walletId } .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwFundingAccount.Trezor( xpub = xpub, @@ -199,25 +229,15 @@ class HwWalletRepo @Inject constructor( } } - suspend fun getWalletId(deviceId: String): Result = withContext(ioDispatcher) { - runSuspendCatching { - val devices = hwWalletStore.loadKnownDevices() - val target = requireNotNull(devices.find { it.id == deviceId }) { - "Unknown hardware wallet '$deviceId'" - } - requireNotNull(target.resolvedWalletId()) { "Hardware wallet '$deviceId' has no wallet id" } - } - } - /** Composes the exact on-chain funding payment before prompting for the Trezor signature. */ suspend fun composeFundingTransaction( - deviceId: String, + walletId: String, address: String, sats: ULong, satsPerVByte: ULong, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val account = getFundingAccount(deviceId).getOrThrow() + val account = getFundingAccount(walletId).getOrThrow() val network = Env.network.toCoreNetwork() val composed = trezorRepo.composeTransaction( extendedKey = account.xpub, @@ -244,7 +264,7 @@ class HwWalletRepo @Inject constructor( /** Signs a composed funding payment on the Trezor. */ suspend fun signFunding( - deviceId: String, + walletId: String, funding: HwFundingTransaction, ): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -253,7 +273,7 @@ class HwWalletRepo @Inject constructor( network = Env.network.toTrezorCoinType(), ).getOrElse { if (!it.isTrezorUserCancellation()) { - trezorRepo.disconnectStaleSession(deviceId) + transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) } } throw it } @@ -281,16 +301,23 @@ class HwWalletRepo @Inject constructor( } } - suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) + suspend fun disconnectStaleSession(walletId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val deviceId = transportDeviceIdOrNull(walletId) ?: return@runSuspendCatching + trezorRepo.disconnectStaleSession(deviceId).getOrThrow() + } + } /** - * Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the + * Persists the Bitkit-side funds label for a paired wallet. Applied to every entry sharing the * same wallet identity so the same device paired over both transports renames consistently. */ - suspend fun setDeviceLabel(deviceId: String, label: String): Result = withContext(ioDispatcher) { - runCatching { + suspend fun setDeviceLabel(walletId: String, label: String): Result = withContext(ioDispatcher) { + runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() - val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } + val target = requireNotNull(devices.find { it.resolvedWalletId() == walletId }) { + "Unknown hardware wallet '$walletId'" + } val customLabel = label.trim().take(DEVICE_LABEL_MAX_LENGTH).ifEmpty { null } val updated = devices.map { if (it.walletKey == target.walletKey) it.copy(customLabel = customLabel) else it @@ -300,37 +327,35 @@ class HwWalletRepo @Inject constructor( } /** - * Removes a paired hardware wallet: stops its watchers and forgets every device entry - * that tracks the same wallet. The same physical device paired over both bluetooth and - * usb is stored once per transport but shares an xpub-derived identity, so forgetting a - * single id would leave the tile reappearing through the other transport. + * Removes a paired hardware wallet: stops its watchers and forgets every device entry that + * tracks the same wallet identity. The same physical device paired over both bluetooth and usb + * is stored once per transport but shares an xpub-derived identity, so forgetting a single id + * would leave the tile reappearing through the other transport. Other identities on the same + * device — the standard wallet, or another passphrase wallet — are left paired. */ - suspend fun removeDevice(deviceId: String): Result = withContext(ioDispatcher) { + suspend fun removeDevice(walletId: String): Result = withContext(ioDispatcher) { runSuspendCatching { watcherMutex.withLock { val knownDevices = hwWalletStore.loadKnownDevices() - val target = knownDevices.find { it.id == deviceId } - val walletId = target?.resolvedWalletId() - val ids = when (target) { - null -> setOf(deviceId) - else -> knownDevices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() - } + val targets = knownDevices.filter { it.resolvedWalletId() == walletId } activeWatchers.toList() - .filter { it.toDeviceId() in ids } + .filter { it.toWalletId() == walletId } .forEach { if (!stopActiveWatcherLocked(it)) { throw AppError("Failed to stop hardware wallet watcher '$it'") } } - walletId?.let { - activityRepo.deleteForWallet(it).getOrThrow() - trackedWalletIds -= it - lastPersistedHwSnapshots -= it + activityRepo.deleteForWallet(walletId).getOrThrow() + trackedWalletIds -= walletId + lastPersistedHwSnapshots -= walletId + val failures = targets.mapNotNull { + trezorRepo.forgetDevice(it.id, walletKey = it.walletKey).exceptionOrNull() } - val failures = ids.mapNotNull { trezorRepo.forgetDevice(it).exceptionOrNull() } - val remaining = hwWalletStore.loadKnownDevices().map { it.id }.toSet() + val remaining = hwWalletStore.loadKnownDevices() failures.firstOrNull()?.let { throw it } - check(ids.none { it in remaining }) { "Hardware wallet '$deviceId' still present after removal" } + check(remaining.none { it.resolvedWalletId() == walletId }) { + "Hardware wallet '$walletId' still present after removal" + } } }.onFailure { watcherSyncRequests.tryEmit(Unit) @@ -344,30 +369,37 @@ class HwWalletRepo @Inject constructor( ) { data, trezorState, watcherData -> // The same physical device paired over both bluetooth and usb is stored as two // entries with different transport-level ids; its xpubs are the cross-transport - // identity, so group by them to show one wallet and count its balance once. + // identity, so group by them to show one wallet and count its balance once. A + // passphrase wallet derives different xpubs, so it groups into its own wallet. data.knownDevices .filter { it.xpubs.isNotEmpty() } .groupBy { it.walletKey } - .map { (_, devices) -> + .mapNotNull { (_, devices) -> + val walletId = devices.firstNotNullOfOrNull { it.resolvedWalletId() } ?: return@mapNotNull null val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } val ids = devices.map { it.id }.toSet() - val deviceWatchers = watcherData.values.filter { it.deviceId in ids } - val fundingBalanceSats = deviceWatchers + val walletWatchers = watcherData.values.filter { it.walletId == walletId } + val fundingBalanceSats = walletWatchers .filter { it.addressType == HwFundingAddressType.DEFAULT.settingsKey } .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwWallet( - id = device.id, + id = walletId, name = device.displayName, model = device.model, transportType = device.transportType, - isConnected = connectedDevice != null, - balanceSats = deviceWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, - activities = deviceWatchers + // A device holding several passphrase wallets only has a session for one of + // them, and only that identity can sign; mark the others disconnected. Sessions + // opened before an identity was resolved report no wallet and stay inclusive. + isConnected = connectedDevice != null && + trezorState.connectedWalletId().let { it == null || it == walletId }, + balanceSats = walletWatchers.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }, + activities = walletWatchers .toMergedActivities() .toImmutableList(), fundingBalanceSats = fundingBalanceSats, deviceIds = ids.toImmutableSet(), + passphraseProtected = devices.any { it.passphraseProtected }, ) } .toImmutableList() @@ -385,12 +417,12 @@ class HwWalletRepo @Inject constructor( hwWalletStore.data, _watcherData, ) { data, watcherData -> - val knownDeviceIds = data.knownDevices + val knownWalletIds = data.knownDevices .filter { it.xpubs.isNotEmpty() } - .map { it.id } + .mapNotNull { it.resolvedWalletId() } .toSet() watcherData.values - .filter { it.deviceId in knownDeviceIds } + .filter { it.walletId in knownWalletIds } .toMergedActivities() .toImmutableList() } @@ -414,7 +446,6 @@ class HwWalletRepo @Inject constructor( .filter { it.walletId == walletId } .toImmutableList() val watcher = HwWatcherData( - deviceId = watcherId.toDeviceId(), walletId = walletId, addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, @@ -572,14 +603,13 @@ class HwWalletRepo @Inject constructor( .filterKeys { it in SUPPORTED_WATCHER_ADDRESS_TYPES } .map { (addressType, xpub) -> WatcherSpec( - deviceId = device.id, addressType = addressType, xpub = xpub, electrumUrl = electrumUrl, walletId = walletId, ) } - }.distinctBy { it.addressType to it.xpub } + }.distinctBy { it.watcherId } private suspend fun stopActiveWatcherLocked(watcherId: String): Boolean = trezorRepo.stopWatcher(watcherId).onSuccess { @@ -662,16 +692,17 @@ class HwWalletRepo @Inject constructor( .firstOrNull { it.v1.txId == txid && it.v1.walletId == walletId } private data class WatcherSpec( - val deviceId: String, val addressType: String, val xpub: String, val electrumUrl: String, val walletId: String, ) { - val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType" + // Keyed by wallet, not by device: a device holding several passphrase wallets would + // otherwise collide on one watcher id per address type. + val watcherId: String get() = "$walletId$WATCHER_ID_SEPARATOR$addressType" } - private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) + private fun String.toWalletId(): String = substringBefore(WATCHER_ID_SEPARATOR) private fun String.toAddressTypeKey(): String = substringAfter(WATCHER_ID_SEPARATOR) } @@ -705,7 +736,6 @@ private val KnownDevice.displayName: String get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel) private data class HwWatcherData( - val deviceId: String, val walletId: String, val addressType: String, val balanceSats: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index aa5fcdda7..59a59f4c3 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -352,12 +352,14 @@ class TrezorRepo @Inject constructor( isBootloader = false, ) } - if (deviceInfo != null) { - addOrUpdateKnownDevice(deviceInfo, features) - } + val known = deviceInfo?.let { addOrUpdateKnownDevice(it, features) } _state.update { it.copy( - connected = ConnectedTrezorDevice(id = deviceId, features = features), + connected = ConnectedTrezorDevice( + id = deviceId, + features = features, + walletId = known?.walletId?.takeIf { id -> id.isNotBlank() }, + ), nearbyDevices = it.nearbyDevices.filter { d -> d.id != deviceId }.toImmutableList(), ) } @@ -701,8 +703,16 @@ class TrezorRepo @Inject constructor( Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) Logger.debug("Connected known device '${device.id}'", context = TAG) - addOrUpdateKnownDevice(device, features) - _state.update { it.copy(connected = ConnectedTrezorDevice(id = device.id, features = features)) } + val known = addOrUpdateKnownDevice(device, features) + _state.update { + it.copy( + connected = ConnectedTrezorDevice( + id = device.id, + features = features, + walletId = known.walletId.takeIf { id -> id.isNotBlank() }, + ) + ) + } Logger.info("Reconnected known device '${device.id}'", context = TAG) features }.onFailure { e -> @@ -735,8 +745,14 @@ class TrezorRepo @Inject constructor( features: TrezorFeatures, ): Result { if (features.pinProtection != true || features.unlocked != false) return Result.success(features) - return runSuspendCatching { trezorService.refreshFeatures() }.onSuccess { - _state.update { state -> state.copy(connected = ConnectedTrezorDevice(id = deviceId, features = it)) } + return runSuspendCatching { trezorService.refreshFeatures() }.onSuccess { refreshed -> + _state.update { state -> + val connected = state.connected + ?.takeIf { it.id == deviceId } + ?.copy(features = refreshed) + ?: ConnectedTrezorDevice(id = deviceId, features = refreshed) + state.copy(connected = connected) + } } } @@ -846,7 +862,13 @@ class TrezorRepo @Inject constructor( throw AppError("Device not found nearby — is it powered on?") } - suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { + /** + * Forgets a paired entry. [walletKey] scopes the removal to a single passphrase identity; + * without it every wallet watched on that physical device is forgotten. Transport and session + * credentials are only cleared once no identity of the device remains, so removing one hidden + * wallet does not unpair the device for the others. + */ + suspend fun forgetDevice(deviceId: String, walletKey: String? = null): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { @@ -862,11 +884,19 @@ class TrezorRepo @Inject constructor( } else { Result.success(Unit) } - TrezorDebugLog.log("FORGET", "Clearing credentials...") - trezorTransport.clearDeviceCredential(deviceId) - val clearCredentialsResult = runCatching { trezorService.clearCredentials(deviceId) } - val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } - val updated = knownDevices.filter { it.id != deviceId } + val knownDevices = (_state.value.knownDevices + loadKnownDevices()) + .distinctBy { it.id to it.walletKey } + val updated = knownDevices.filterNot { + it.id == deviceId && (walletKey == null || it.walletKey == walletKey) + } + val clearCredentialsResult = if (updated.none { it.id == deviceId }) { + TrezorDebugLog.log("FORGET", "Clearing credentials...") + trezorTransport.clearDeviceCredential(deviceId) + runCatching { trezorService.clearCredentials(deviceId) } + } else { + TrezorDebugLog.log("FORGET", "Keeping credentials, another wallet still uses $deviceId") + Result.success(Unit) + } saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } clearCredentialsResult.getOrThrow() @@ -1037,12 +1067,21 @@ class TrezorRepo @Inject constructor( needsPairingCode.value } - private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures): KnownDevice { val stored = hwWalletStore.loadKnownDevices() - val storedIds = stored.map { it.id }.toSet() - val knownDevices = stored + _state.value.knownDevices.filter { it.id !in storedIds } - val previous = knownDevices.find { it.id == deviceInfo.id } + val storedEntries = stored.map { it.id to it.walletKey }.toSet() + val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } val fetchResult = fetchAccountXpubs() + val isPassphraseSession = trezorUiHandler.currentSelection() != WalletSelection.Standard + // A passphrase wallet is a separate identity on the same physical device, so the transport + // id alone no longer identifies an entry: matching by it would overwrite another identity + // or blend two identities' xpubs into one record. Shared key material is the identity, so + // match on it; only an entry stored before any xpub was captured has no identity to + // conflict with and can be adopted by this connect. + val candidates = knownDevices.filter { it.id == deviceInfo.id } + val previous = candidates.firstOrNull { + it.xpubs.values.intersect(fetchResult.xpubs.values.toSet()).isNotEmpty() + } ?: candidates.singleOrNull()?.takeIf { it.xpubs.isEmpty() } val xpubs = previous?.xpubs.orEmpty() + fetchResult.xpubs val retryableGaps = fetchResult.transientFailures.filterKeys { addressType -> xpubs[addressType.toSettingsString()] == null @@ -1066,11 +1105,21 @@ class TrezorRepo @Inject constructor( lastConnectedAt = clock.nowMs(), xpubs = xpubs, customLabel = previous?.customLabel, - walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), + walletId = previous?.walletId?.takeIf { it.isNotBlank() } + ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id), + // The selection bound to the session is what derived these xpubs. + passphraseProtected = previous?.passphraseProtected == true || isPassphraseSession, ) - val updated = knownDevices.filter { it.id != known.id } + known + // Replace the entry this connect refreshed, plus any entry already holding the resulting + // identity: reading a previously rejected address type changes the walletKey, and matching + // on the new key alone would leave the stale entry behind as a duplicate wallet. + val updated = knownDevices.filterNot { + (it.id == known.id && it.walletKey == known.walletKey) || + (previous != null && it.id == previous.id && it.walletKey == previous.walletKey) + } + known saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } + return known } /** @@ -1159,7 +1208,13 @@ class TrezorRepo @Inject constructor( allowBleFallback = true, ) val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) - _state.update { it.copy(connected = ConnectedTrezorDevice(id = deviceId, features = features)) } + _state.update { state -> + val connected = state.connected + ?.takeIf { it.id == deviceId } + ?.copy(features = features) + ?: ConnectedTrezorDevice(id = deviceId, features = features) + state.copy(connected = connected) + } } private suspend fun awaitSetup(walletIndex: Int = 0) { @@ -1338,12 +1393,16 @@ data class TrezorState( fun connectedDevice(): TrezorFeatures? = connected?.features fun connectedDeviceId(): String? = connected?.id + + fun connectedWalletId(): String? = connected?.walletId } @Stable data class ConnectedTrezorDevice( val id: String, val features: TrezorFeatures, + /** Identity the live session was opened for; a device can hold several passphrase wallets. */ + val walletId: String? = null, ) private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId @@ -1361,10 +1420,9 @@ private fun deriveHardwareWalletId(xpubs: Map): String? = runCatching { HwWalletId.derive(xpubs) }.getOrNull() } -private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { - val walletKey = walletKey(xpubs, deviceId) - return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } - ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } +private fun List.findHardwareWalletId(xpubs: Map, fallback: String): String { + val walletKey = walletKey(xpubs, fallback) + return firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } ?: deriveHardwareWalletId(xpubs).orEmpty() } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 384cc9468..defd01b52 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -804,10 +804,10 @@ private fun RootNavHost( ) } deepLinkableComposable { entry -> - val deviceId = entry.toRoute().deviceId + val walletId = entry.toRoute().walletId SpendingIntroScreen( onContinueClick = { - navController.navigateTo(Routes.SpendingAmountHw(deviceId)) + navController.navigateTo(Routes.SpendingAmountHw(walletId)) settingsViewModel.setHasSeenSpendingIntro(true) }, onBackClick = { navController.popBackStack() }, @@ -831,20 +831,20 @@ private fun RootNavHost( ) } deepLinkableComposable { entry -> - val deviceId = entry.toRoute().deviceId + val walletId = entry.toRoute().walletId val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountHwScreen( - deviceId = deviceId, + walletId = walletId, viewModel = transferViewModel, isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, - onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(deviceId)) }, + onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, ) } composableWithDefaultTransitions { entry -> - val deviceId = entry.toRoute().deviceId + val walletId = entry.toRoute().walletId SpendingHwSignScreen( - deviceId = deviceId, + walletId = walletId, viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, @@ -1075,10 +1075,10 @@ private fun NavGraphBuilder.home( ) } deepLinkableComposable { - val deviceId = it.toRoute().deviceId + val walletId = it.toRoute().walletId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( - deviceId = deviceId, + walletId = walletId, onActivityItemClick = { navController.navToActivityDetail(it) }, onTransferToSpendingClick = { selectedDeviceId -> navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) @@ -1906,8 +1906,8 @@ fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) fun NavController.navigateToTransferSpendingStart( hasSeenSpendingIntro: Boolean, - deviceId: String, -) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) + walletId: String, +) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, walletId)) internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean = handled && currentSheet != null @@ -1925,10 +1925,10 @@ internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = internal fun transferSpendingStartRoute( hasSeenSpendingIntro: Boolean, - deviceId: String, + walletId: String, ): Routes = when { - hasSeenSpendingIntro -> Routes.SpendingAmountHw(deviceId) - else -> Routes.SpendingIntroHw(deviceId) + hasSeenSpendingIntro -> Routes.SpendingAmountHw(walletId) + else -> Routes.SpendingIntroHw(walletId) } fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) @@ -1978,7 +1978,7 @@ sealed interface Routes { data object Spending : Routes.DeepLinkable @Serializable - data class HardwareWallet(val deviceId: String) : Routes.DeepLinkable + data class HardwareWallet(val walletId: String) : Routes.DeepLinkable @Serializable data object Settings : Routes.DeepLinkable @@ -2106,16 +2106,16 @@ sealed interface Routes { data object SpendingIntro : Routes.DeepLinkable @Serializable - data class SpendingIntroHw(val deviceId: String) : Routes.DeepLinkable + data class SpendingIntroHw(val walletId: String) : Routes.DeepLinkable @Serializable data object SpendingAmount : Routes.DeepLinkable @Serializable - data class SpendingAmountHw(val deviceId: String) : Routes.DeepLinkable + data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable @Serializable - data class SpendingHwSign(val deviceId: String) : Routes.InternalOnly + data class SpendingHwSign(val walletId: String) : Routes.InternalOnly @Serializable data object SpendingHwSigned : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index 5d0431673..eef71f839 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -61,7 +61,7 @@ import to.bitkit.viewmodels.previewAmountInputViewModel @Suppress("ViewModelForwarding") @Composable fun SpendingAmountHwScreen( - deviceId: String, + walletId: String, viewModel: TransferViewModel, isOffline: Boolean, onBackClick: () -> Unit = {}, @@ -76,8 +76,8 @@ fun SpendingAmountHwScreen( val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) val currentCurrencies by rememberUpdatedState(currencies) - LaunchedEffect(deviceId, isOffline) { - viewModel.updateHwLimits(deviceId) + LaunchedEffect(walletId, isOffline) { + viewModel.updateHwLimits(walletId) } LaunchedEffect(Unit) { 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 3c4a94780..754c6b46f 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 @@ -41,7 +41,7 @@ import to.bitkit.viewmodels.TransferViewModel @Composable fun SpendingHwSignScreen( - deviceId: String, + walletId: String, viewModel: TransferViewModel, onBackClick: () -> Unit, onCloseClick: () -> Unit, @@ -55,9 +55,9 @@ fun SpendingHwSignScreen( return } - LaunchedEffect(deviceId, order.id) { - viewModel.warmUpHardwareConnection(deviceId) - viewModel.updateHwFundingFeeEstimate(order, deviceId) + LaunchedEffect(walletId, order.id) { + viewModel.warmUpHardwareConnection(walletId) + viewModel.updateHwFundingFeeEstimate(order, walletId) } DisposableEffect(viewModel) { @@ -74,7 +74,7 @@ fun SpendingHwSignScreen( onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, deviceId) }, + onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, walletId) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 159bafd79..b1d49cac5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -61,7 +61,7 @@ import to.bitkit.ui.theme.TopBarGradient @Composable fun HardwareWalletScreen( - deviceId: String, + walletId: String, onActivityItemClick: (Activity) -> Unit, onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, @@ -70,7 +70,7 @@ fun HardwareWalletScreen( val wallets by viewModel.wallets.collectAsStateWithLifecycle() val walletsLoaded by viewModel.walletsLoaded.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val wallet = remember(wallets, deviceId) { wallets.find { deviceId in it.deviceIds } } + val wallet = remember(wallets, walletId) { wallets.find { it.id == walletId } } // Leave the screen once the device is gone, whether removed here or forgotten elsewhere. LaunchedEffect(wallet, walletsLoaded) { @@ -84,7 +84,7 @@ fun HardwareWalletScreen( onActivityItemClick = onActivityItemClick, onTransferToSpendingClick = onTransferToSpendingClick, onRemoveClick = { viewModel.onRemoveClick(device) }, - onConfirmRemove = { viewModel.removeDevice(deviceId) }, + onConfirmRemove = { viewModel.removeDevice(walletId) }, onDismissRemoveDialog = viewModel::onDismissRemoveDialog, onBackClick = onBackClick, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt index 54224d872..bf71316d5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HwWalletViewModel.kt @@ -108,13 +108,13 @@ class HwWalletViewModel @Inject constructor( } } - private fun HwWalletDetailUiState.matchesRenameSession(deviceId: String, sessionId: Long) = - renameSessionId == sessionId && isPendingRename?.id == deviceId + private fun HwWalletDetailUiState.matchesRenameSession(walletId: String, sessionId: Long) = + renameSessionId == sessionId && isPendingRename?.id == walletId - fun removeDevice(deviceId: String) { + fun removeDevice(walletId: String) { viewModelScope.launch { _uiState.update { it.copy(isPendingRemoval = null) } - hwWalletRepo.removeDevice(deviceId).onFailure { + hwWalletRepo.removeDevice(walletId).onFailure { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 392abc1a2..a21a3054c 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -156,13 +156,13 @@ class HwConnectViewModel @Inject constructor( fun onLabelChange(value: String) = _uiState.update { it.copy(labelInput = value.take(DEVICE_LABEL_MAX_LENGTH)) } fun onFinishClick() { - val deviceId = _uiState.value.pairedDeviceId - if (deviceId == null) { + val walletId = _uiState.value.pairedWalletId + if (walletId == null) { setEffect(HwConnectEffect.Dismiss) return } viewModelScope.launch { - hwWalletRepo.setDeviceLabel(deviceId, _uiState.value.labelInput) + hwWalletRepo.setDeviceLabel(walletId, _uiState.value.labelInput) setEffect(HwConnectEffect.Finish) } } @@ -240,9 +240,10 @@ class HwConnectViewModel @Inject constructor( viewModelScope.launch { hwWalletRepo.wallets.collect { wallets -> val deviceId = _uiState.value.pairedDeviceId ?: return@collect - val wallet = wallets.firstOrNull { deviceId == it.id || deviceId in it.deviceIds } ?: return@collect + val wallet = wallets.firstOrNull { deviceId in it.deviceIds } ?: return@collect _uiState.update { it.copy( + pairedWalletId = wallet.id, deviceName = wallet.name, balanceSats = wallet.balanceSats, labelInput = if (labelInitialized) it.labelInput else wallet.name, @@ -262,6 +263,8 @@ data class HwConnectUiState( val isConnecting: Boolean = false, val foundDeviceId: String? = null, val pairedDeviceId: String? = null, + /** Identity paired on [pairedDeviceId]; resolved once its watch-only wallet is known. */ + val pairedWalletId: String? = null, val deviceName: String = "", val deviceModel: String = "", val balanceSats: ULong = 0uL, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e08c0d3e9..11a45d634 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -121,7 +121,7 @@ class TransferViewModel @Inject constructor( private var confirmPayJob: Job? = null private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null - private var activeHwTransferDeviceId: String? = null + private var activeHwTransferWalletId: String? = null // region Spending @@ -719,22 +719,22 @@ class TransferViewModel @Inject constructor( // Do not cancel confirmPayJob: broadcast + paid-order cache must finish. spendingConfirmFundingPlan = null pendingHwFundingBroadcast = null - activeHwTransferDeviceId = null + activeHwTransferWalletId = null _spendingUiState.update { TransferToSpendingUiState() } _transferValues.update { TransferValues() } } fun cancelHardwareTransfer() { if (pendingHwFundingBroadcast != null) return - val deviceId = activeHwTransferDeviceId + val walletId = activeHwTransferWalletId hwTransferSignJob?.cancel() hwTransferSignJob = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null _spendingUiState.update { it.copy(isSigning = false) } - if (deviceId != null) { + if (walletId != null) { viewModelScope.launch { - hwWalletRepo.disconnectStaleSession(deviceId) + hwWalletRepo.disconnectStaleSession(walletId) } } } @@ -743,11 +743,11 @@ class TransferViewModel @Inject constructor( // region Hardware Wallet - fun updateHwLimits(deviceId: String) { + fun updateHwLimits(walletId: String) { viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - val account = hwWalletRepo.getFundingAccount(deviceId).getOrElse { + val account = hwWalletRepo.getFundingAccount(walletId).getOrElse { Logger.error("Failed to load hardware funding account", it, context = TAG) _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } setTransferEffect(TransferEffect.ToastException(it)) @@ -771,12 +771,12 @@ 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 warmUpHardwareConnection(walletId: String) { + hwWalletRepo.warmUpKnownDevice(walletId) } /** Best-effort offline mining-fee estimate for the Sign screen (xpub compose, no device session). */ - fun updateHwFundingFeeEstimate(order: IBtOrder, deviceId: String) { + fun updateHwFundingFeeEstimate(order: IBtOrder, walletId: String) { hwFeeEstimateJob?.cancel() hwFeeEstimateJob = viewModelScope.launch { if (_spendingUiState.value.hasPendingHwBroadcast) return@launch @@ -787,7 +787,7 @@ class TransferViewModel @Inject constructor( runSuspendCatching { val satsPerVByte = hwFundingSatsPerVByte() hwWalletRepo.composeFundingTransaction( - deviceId = deviceId, + walletId = walletId, address = address, sats = order.feeSat, satsPerVByte = satsPerVByte, @@ -803,17 +803,17 @@ class TransferViewModel @Inject constructor( } }.onFailure { Logger.debug( - "Skipped offline hardware funding fee estimate for '$deviceId'", + "Skipped offline hardware funding fee estimate for '$walletId'", context = TAG, ) } } } - fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { + fun onTransferToSpendingHwConfirm(order: IBtOrder, walletId: String) { if (hwTransferSignJob?.isActive == true) return - activeHwTransferDeviceId = deviceId + activeHwTransferWalletId = walletId hwTransferSignJob = viewModelScope.launch { _spendingUiState.update { it.copy(isSigning = true) } try { @@ -822,12 +822,7 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) return@launch } - val walletId = hwWalletRepo.getWalletId(deviceId).getOrElse { - handleHardwareTransferFailure(it, deviceId) - return@launch - } - - signAndBroadcastHardwareFunding(order, deviceId, address) + signAndBroadcastHardwareFunding(order, walletId, address) .onSuccess { result -> runSuspendCatching { fundPaidOrder( @@ -840,15 +835,15 @@ class TransferViewModel @Inject constructor( ) }.onSuccess { pendingHwFundingBroadcast = null - activeHwTransferDeviceId = null + activeHwTransferWalletId = null _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } setTransferEffect(TransferEffect.OnHwTxSigned) }.onFailure { Logger.error("Failed to record broadcast hardware transfer", it, context = TAG) - handleHardwareTransferFailure(it, deviceId) + handleHardwareTransferFailure(it, walletId) } } - .onFailure { handleHardwareTransferFailure(it, deviceId) } + .onFailure { handleHardwareTransferFailure(it, walletId) } } finally { _spendingUiState.update { it.copy(isSigning = false) } hwTransferSignJob = null @@ -858,20 +853,20 @@ class TransferViewModel @Inject constructor( private suspend fun signAndBroadcastHardwareFunding( order: IBtOrder, - deviceId: String, + walletId: String, address: String, ): Result { val result = runCatching { val signedTx = pendingHwFundingBroadcast - ?.takeIf { it.matches(order, deviceId, address) } + ?.takeIf { it.matches(order, walletId, address) } ?.signedTx ?.also { pending -> _spendingUiState.update { state -> state.copy(hwMiningFeeSats = pending.miningFeeSats) } } - ?: prepareSignedHardwareFunding(order, deviceId, address).also { + ?: prepareSignedHardwareFunding(order, walletId, address).also { pendingHwFundingBroadcast = PendingHwFundingBroadcast( orderId = order.id, - deviceId = deviceId, + walletId = walletId, address = address, amountSats = order.feeSat, signedTx = it, @@ -891,26 +886,26 @@ class TransferViewModel @Inject constructor( private suspend fun prepareSignedHardwareFunding( order: IBtOrder, - deviceId: String, + walletId: String, address: String, ): HwFundingSignedTx { - ensureHardwareConnected(deviceId) + ensureHardwareConnected(walletId) val satsPerVByte = hwFundingSatsPerVByte() val funding = composeHardwareFundingTransaction( - deviceId = deviceId, + walletId = walletId, address = address, sats = order.feeSat, satsPerVByte = satsPerVByte, ) _spendingUiState.update { it.copy(hwMiningFeeSats = funding.miningFeeSats) } - return signHardwareFunding(deviceId, funding) + return signHardwareFunding(walletId, funding) } @Suppress("ThrowsCount") - private suspend fun ensureHardwareConnected(deviceId: String) { + private suspend fun ensureHardwareConnected(walletId: String) { runCatching { withTimeout(HW_RECONNECT_TIMEOUT) { - hwWalletRepo.ensureConnected(deviceId).getOrThrow() + hwWalletRepo.ensureConnected(walletId).getOrThrow() } }.getOrElse { it.rethrowIfCancellation() @@ -920,14 +915,14 @@ class TransferViewModel @Inject constructor( } private suspend fun composeHardwareFundingTransaction( - deviceId: String, + walletId: String, address: String, sats: ULong, satsPerVByte: ULong, ): HwFundingTransaction = runCatching { withTimeout(HW_COMPOSE_TIMEOUT) { hwWalletRepo.composeFundingTransaction( - deviceId = deviceId, + walletId = walletId, address = address, sats = sats, satsPerVByte = satsPerVByte, @@ -940,20 +935,20 @@ class TransferViewModel @Inject constructor( @Suppress("ThrowsCount") private suspend fun signHardwareFunding( - deviceId: String, + walletId: String, funding: HwFundingTransaction, ): HwFundingSignedTx { return runCatching { withTimeout(HW_SIGN_TIMEOUT) { hwWalletRepo.signFunding( - deviceId = deviceId, + walletId = walletId, funding = funding, ).getOrThrow() } }.getOrElse { it.rethrowIfCancellation() if (it is TimeoutCancellationException) { - hwWalletRepo.disconnectStaleSession(deviceId) + hwWalletRepo.disconnectStaleSession(walletId) throw HardwareSigningTimeoutError(it) } throw it @@ -973,13 +968,13 @@ class TransferViewModel @Inject constructor( } } - private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { + private suspend fun handleHardwareTransferFailure(e: Throwable, walletId: String) { if (e.isTrezorUserCancellation()) { - Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) + Logger.info("Hardware transfer cancelled on device for '$walletId'", context = TAG) return } if (e.isTrezorDeviceBusy()) { - Logger.warn("Blocked hardware transfer for locked or busy Trezor '$deviceId'", e, context = TAG) + Logger.warn("Blocked hardware transfer for locked or busy Trezor '$walletId'", e, context = TAG) ToastEventBus.send( type = Toast.ToastType.INFO, title = context.getString(R.string.hardware__device_busy), @@ -987,7 +982,7 @@ class TransferViewModel @Inject constructor( return } if (e.isTrezorFirmwareError()) { - Logger.warn("Received Trezor firmware error for '$deviceId'", e, context = TAG) + Logger.warn("Received Trezor firmware error for '$walletId'", e, context = TAG) showHardwareReconnectRequiredError() return } @@ -998,14 +993,14 @@ class TransferViewModel @Inject constructor( } is HardwareReconnectError -> { Logger.error("Failed to reconnect hardware device", e, context = TAG) - showHardwareReconnectError(deviceId) + showHardwareReconnectError(walletId) } is HardwareSigningTimeoutError -> { - Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) + Logger.warn("Timed out hardware transfer signing for '$walletId'", e, context = TAG) showHardwareTimeoutError() } is HardwareFundingError -> { - Logger.warn("Failed to compose hardware transfer funding for '$deviceId'", e, context = TAG) + Logger.warn("Failed to compose hardware transfer funding for '$walletId'", e, context = TAG) if (e.isHardwareInteractionTimeout()) { showHardwareConnectivityError() } else { @@ -1041,8 +1036,8 @@ class TransferViewModel @Inject constructor( this is HardwareFundingError && generateSequence(this) { it.cause }.any { it is TimeoutCancellationException } - private suspend fun showHardwareReconnectError(deviceId: String) { - if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { + private suspend fun showHardwareReconnectError(walletId: String) { + if (hwWalletRepo.isKnownBluetoothDevice(walletId)) { ToastEventBus.send( type = Toast.ToastType.INFO, title = context.getString(R.string.hardware__connect_title), @@ -1560,14 +1555,14 @@ private class HardwareBroadcastError(cause: Throwable) : AppError(cause) private data class PendingHwFundingBroadcast( val orderId: String, - val deviceId: String, + val walletId: String, val address: String, val amountSats: ULong, val signedTx: HwFundingSignedTx, ) { - fun matches(order: IBtOrder, deviceId: String, address: String): Boolean = + fun matches(order: IBtOrder, walletId: String, address: String): Boolean = orderId == order.id && - this.deviceId == deviceId && + this.walletId == walletId && this.address == address && amountSats == order.feeSat } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index b92bfa2cd..8d896a6eb 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -118,7 +118,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() val wallet = sut.wallets.value.single() - assertEquals("dev1", wallet.id) + assertEquals(HARDWARE_WALLET_ID, wallet.id) assertEquals(setOf("dev1"), wallet.deviceIds) assertEquals("Trezor", wallet.name) assertEquals(0uL, wallet.balanceSats) @@ -158,7 +158,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 10_562_411uL), activities = listOf(watcherActivity(amount = 10_562_411uL)), transactionDetails = emptyList(), @@ -200,8 +200,8 @@ class HwWalletRepoTest : BaseUnitTest() { }.thenReturn(Result.success(listOf(persistedActivity))) val sut = createRepo() - watcherEvents.emit("dev1|nativeSegwit" to event) - watcherEvents.emit("dev1|nativeSegwit" to event) + watcherEvents.emit("hardware-wallet|nativeSegwit" to event) + watcherEvents.emit("hardware-wallet|nativeSegwit" to event) assertTrue((sut.activities.value.single() as Activity.Onchain).v1.isTransfer) verify(activityRepo).persistHwSnapshot( @@ -235,9 +235,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) val sut = createRepo() - watcherEvents.emit("dev1|nativeSegwit" to pending) - watcherEvents.emit("dev1|nativeSegwit" to refreshedPending) - watcherEvents.emit("dev1|nativeSegwit" to confirmed) + watcherEvents.emit("hardware-wallet|nativeSegwit" to pending) + watcherEvents.emit("hardware-wallet|nativeSegwit" to refreshedPending) + watcherEvents.emit("hardware-wallet|nativeSegwit" to confirmed) assertTrue((sut.activities.value.single() as Activity.Onchain).v1.confirmed) verify(activityRepo, times(2)).persistHwSnapshot( @@ -252,7 +252,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -261,7 +261,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( + "hardware-wallet|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -281,7 +281,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 200uL), activities = listOf( watcherActivity(amount = 100uL, txid = "older", timestamp = 1_600_000_000uL), @@ -305,7 +305,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), transactionDetails = emptyList(), @@ -315,7 +315,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( + "hardware-wallet|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), transactionDetails = emptyList(), @@ -346,7 +346,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), transactionDetails = emptyList(), @@ -356,7 +356,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) watcherEvents.emit( - "dev2|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet-2|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), activities = listOf( watcherActivity(amount = 50uL, txid = "shared", walletId = secondWalletId) @@ -381,7 +381,7 @@ class HwWalletRepoTest : BaseUnitTest() { val fee = 1_000uL watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), activities = listOf( watcherActivity(amount = 40_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), @@ -393,7 +393,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( + "hardware-wallet|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), activities = listOf( watcherActivity(amount = 20_000uL, txid = "sent-shared", txType = PaymentType.SENT, fee = fee), @@ -423,7 +423,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(pendingActivity), transactionDetails = emptyList(), @@ -435,7 +435,7 @@ class HwWalletRepoTest : BaseUnitTest() { val firstTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(pendingActivity), transactionDetails = emptyList(), @@ -468,9 +468,9 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - 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()) + verifyStartWatcher("hardware-wallet|nativeSegwit") + verifyNoStartWatcher("hardware-wallet|taproot") + verifyNoStartWatcher("hardware-wallet|legacy") } @Test @@ -482,7 +482,7 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("hardware-wallet|nativeSegwit"), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), gapLimit = any(), @@ -506,9 +506,9 @@ class HwWalletRepoTest : BaseUnitTest() { settingsData.value = settingsData.value.copy(electrumServer = secondServer) runCurrent() - verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("hardware-wallet|nativeSegwit"), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), gapLimit = any(), @@ -519,7 +519,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `restarts active watchers when wallet id changes`() = test { + fun `moves the watcher to the new id when the wallet id changes`() = test { val derivedWalletId = "derived-zpubNS" storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = "legacy-wallet-id"))) wheneverStartWatcher().thenReturn(Result.success(Unit)) @@ -530,7 +530,7 @@ class HwWalletRepoTest : BaseUnitTest() { val order = inOrder(trezorRepo) order.verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("legacy-wallet-id|nativeSegwit"), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), gapLimit = any(), @@ -542,9 +542,10 @@ class HwWalletRepoTest : BaseUnitTest() { storeData.value = HwWalletData(knownDevices = listOf(device.copy(walletId = derivedWalletId))) runCurrent() - order.verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + // The watcher is keyed by wallet, so a new identity starts its own watcher and the + // watcher of the id that no longer exists is stopped afterwards. order.verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("$derivedWalletId|nativeSegwit"), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), gapLimit = any(), @@ -552,6 +553,7 @@ class HwWalletRepoTest : BaseUnitTest() { electrumUrl = any(), walletId = eq(derivedWalletId), ) + order.verify(trezorRepo).stopWatcher("legacy-wallet-id|nativeSegwit") } @Test @@ -563,7 +565,7 @@ class HwWalletRepoTest : BaseUnitTest() { runCurrent() verify(trezorRepo).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("derived-zpubNS|nativeSegwit"), extendedKey = eq("zpubNS"), network = eq(Env.network.toCoreNetwork()), gapLimit = any(), @@ -579,15 +581,20 @@ class HwWalletRepoTest : BaseUnitTest() { createRepo() - verify(trezorRepo).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verifyStartWatcher("hardware-wallet|nativeSegwit") advanceTimeBy(30.seconds) runCurrent() - verify( - trezorRepo, - times(2) - ).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify(trezorRepo, times(2)).startWatcher( + eq("hardware-wallet|nativeSegwit"), + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + ) } @Test @@ -599,7 +606,7 @@ class HwWalletRepoTest : BaseUnitTest() { // Baseline: full history delivered on watcher start must not emit. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(watcherActivity(amount = 100uL)), transactionDetails = emptyList(), @@ -613,7 +620,7 @@ class HwWalletRepoTest : BaseUnitTest() { // New inbound tx after the baseline emits once. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), activities = listOf( watcherActivity(amount = 100uL), @@ -639,7 +646,7 @@ class HwWalletRepoTest : BaseUnitTest() { // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 150uL), activities = listOf( watcherActivity(amount = 100uL), @@ -676,11 +683,11 @@ class HwWalletRepoTest : BaseUnitTest() { runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to transactionsChanged(100uL, baseline) + "hardware-wallet|nativeSegwit" to transactionsChanged(100uL, baseline) ) runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to transactionsChanged(150uL, updated) + "hardware-wallet|nativeSegwit" to transactionsChanged(150uL, updated) ) runCurrent() @@ -691,7 +698,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertTrue(received.isEmpty()) watcherEvents.emit( - "dev1|nativeSegwit" to transactionsChanged(150uL, updated) + "hardware-wallet|nativeSegwit" to transactionsChanged(150uL, updated) ) runCurrent() @@ -716,7 +723,7 @@ class HwWalletRepoTest : BaseUnitTest() { runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -726,7 +733,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) runCurrent() watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( + "hardware-wallet|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 0uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -737,7 +744,7 @@ class HwWalletRepoTest : BaseUnitTest() { runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = listOf(watcherActivity(amount = 100uL, txid = "shared")), transactionDetails = emptyList(), @@ -748,7 +755,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) runCurrent() watcherEvents.emit( - "dev1|taproot" to WatcherEvent.TransactionsChanged( + "hardware-wallet|taproot" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 50uL), activities = listOf(watcherActivity(amount = 50uL, txid = "shared")), transactionDetails = emptyList(), @@ -773,7 +780,7 @@ class HwWalletRepoTest : BaseUnitTest() { val job = launch { sut.receivedTxs.collect { received += it } } watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -782,7 +789,7 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 40uL), activities = listOf( watcherActivity(amount = 60uL, txid = "t3", txType = PaymentType.SENT), @@ -807,14 +814,20 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() - verify(trezorRepo).startWatcher(eq("ble1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) - verify( - trezorRepo, - never() - ).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + // Both transport entries share one identity, so they resolve to a single wallet watcher. + verify(trezorRepo).startWatcher( + eq("hardware-wallet|nativeSegwit"), + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + ) + verify(trezorRepo, times(1)).startWatcher(any(), any(), any(), any(), anyOrNull(), any(), any()) watcherEvents.emit( - "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 421_900uL), activities = listOf(watcherActivity(amount = 421_900uL)), transactionDetails = emptyList(), @@ -846,7 +859,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() val wallet = sut.wallets.value.single() - assertEquals("usb1", wallet.id) + assertEquals(HARDWARE_WALLET_ID, wallet.id) assertEquals(setOf("ble1", "usb1"), wallet.deviceIds) assertEquals(TransportType.USB, wallet.transportType) assertEquals(true, wallet.isConnected) @@ -865,7 +878,7 @@ class HwWalletRepoTest : BaseUnitTest() { runCurrent() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -903,7 +916,7 @@ class HwWalletRepoTest : BaseUnitTest() { storeData.value = HwWalletData(knownDevices = emptyList()) runCurrent() - verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) } @@ -932,7 +945,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() watcherEvents.emit( - "dev1|nativeSegwit" to WatcherEvent.TransactionsChanged( + "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( balance = walletBalance(total = 100uL), activities = emptyList(), transactionDetails = emptyList(), txCount = 0u, @@ -943,7 +956,7 @@ class HwWalletRepoTest : BaseUnitTest() { sut.resetState() - verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") verify(trezorRepo).resetState() assertEquals(0uL, sut.totalSats.value) } @@ -953,28 +966,28 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) - whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.success(Unit)) val sut = createRepo() runCurrent() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) assertEquals(true, result.isSuccess) - verify(trezorRepo).stopWatcher("dev1|nativeSegwit") + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) - verify(trezorRepo).forgetDevice("dev1") + verify(trezorRepo).forgetDevice("dev1", "zpubNS") } @Test fun `removeDevice fails when forget reports credential cleanup failure despite the device being gone`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), emptyList()) - whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.failure(AppError("clear failed"))) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.failure(AppError("clear failed"))) val sut = createRepo() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) assertEquals(true, result.isFailure) - verify(trezorRepo).forgetDevice("dev1") + verify(trezorRepo).forgetDevice("dev1", "zpubNS") } @Test @@ -985,11 +998,11 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() runCurrent() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) assertEquals(true, result.isFailure) - verify(trezorRepo).stopWatcher("dev1|nativeSegwit") - verify(trezorRepo, never()).forgetDevice(any()) + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") + verify(trezorRepo, never()).forgetDevice(any(), anyOrNull()) } @Test @@ -999,11 +1012,11 @@ class HwWalletRepoTest : BaseUnitTest() { .thenReturn(Result.failure(AppError("delete failed"))) val sut = createRepo() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) assertTrue(result.isFailure) verify(activityRepo).deleteForWallet(HARDWARE_WALLET_ID) - verify(trezorRepo, never()).forgetDevice(any()) + verify(trezorRepo, never()).forgetDevice(any(), anyOrNull()) } @Test @@ -1014,24 +1027,24 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(bleEntry, usbEntry), emptyList()) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) - whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.success(Unit)) val sut = createRepo() runCurrent() - sut.removeDevice("usb1") + sut.removeDevice(HARDWARE_WALLET_ID) - verify(trezorRepo).stopWatcher("ble1|nativeSegwit") - verify(trezorRepo).forgetDevice("ble1") - verify(trezorRepo).forgetDevice("usb1") + verify(trezorRepo).stopWatcher("hardware-wallet|nativeSegwit") + verify(trezorRepo).forgetDevice("ble1", "zpubNS") + verify(trezorRepo).forgetDevice("usb1", "zpubNS") } @Test fun `removeDevice fails when the device is still present afterwards`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), listOf(device)) - whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.success(Unit)) val sut = createRepo() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) assertEquals(true, result.isFailure) } @@ -1041,16 +1054,16 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device), listOf(device)) wheneverStartWatcher().thenReturn(Result.success(Unit)) whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) - whenever { trezorRepo.forgetDevice(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.success(Unit)) val sut = createRepo() runCurrent() - val result = sut.removeDevice("dev1") + val result = sut.removeDevice(HARDWARE_WALLET_ID) runCurrent() assertEquals(true, result.isFailure) verify(trezorRepo, times(2)).startWatcher( - watcherId = eq("dev1|nativeSegwit"), + watcherId = eq("hardware-wallet|nativeSegwit"), extendedKey = any(), network = any(), gapLimit = any(), @@ -1079,10 +1092,12 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `forwards warm up known device to the trezor repo`() = test { + fun `warms up the transport entry of the requested wallet`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) val sut = createRepo() - sut.warmUpKnownDevice("dev1") + sut.warmUpKnownDevice(HARDWARE_WALLET_ID) + runCurrent() verify(trezorRepo).warmUpKnownDevice("dev1") } @@ -1109,7 +1124,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() val result = sut.composeFundingTransaction( - deviceId = "dev1", + walletId = HARDWARE_WALLET_ID, address = "bc1qtest", sats = 25_000uL, satsPerVByte = 2uL, @@ -1138,7 +1153,7 @@ class HwWalletRepoTest : BaseUnitTest() { val sut = createRepo() val result = sut.composeFundingTransaction( - deviceId = "dev1", + walletId = HARDWARE_WALLET_ID, address = "bc1qtest", sats = 25_000uL, satsPerVByte = 2uL, @@ -1168,7 +1183,7 @@ class HwWalletRepoTest : BaseUnitTest() { .thenReturn(Result.success(signedTx)) val sut = createRepo() - val result = sut.signFunding("dev1", funding) + val result = sut.signFunding(HARDWARE_WALLET_ID, funding) assertEquals(true, result.isSuccess) assertEquals("rawtx", result.getOrThrow().serializedTx) @@ -1228,9 +1243,10 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) .thenReturn(Result.failure(AppError("sign failed"))) whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) val sut = createRepo() - val result = sut.signFunding("dev1", funding) + val result = sut.signFunding(HARDWARE_WALLET_ID, funding) assertEquals(true, result.isFailure) verify(trezorRepo).disconnectStaleSession("dev1") @@ -1250,7 +1266,7 @@ class HwWalletRepoTest : BaseUnitTest() { .thenReturn(Result.failure(TrezorException.UserCancelled())) val sut = createRepo() - val result = sut.signFunding("dev1", funding) + val result = sut.signFunding(HARDWARE_WALLET_ID, funding) assertEquals(true, result.isFailure) verify(trezorRepo, never()).disconnectStaleSession(any()) @@ -1360,7 +1376,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) val sut = createRepo() - val result = sut.setDeviceLabel("dev1", " My Cold Wallet ") + val result = sut.setDeviceLabel(HARDWARE_WALLET_ID, " My Cold Wallet ") assertTrue(result.isSuccess) verify(hwWalletStore).saveKnownDevices(listOf(device.copy(customLabel = "My Cold Wallet"))) @@ -1371,7 +1387,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) val sut = createRepo() - val result = sut.setDeviceLabel("dev1", "a".repeat(51)) + val result = sut.setDeviceLabel(HARDWARE_WALLET_ID, "a".repeat(51)) assertTrue(result.isSuccess) verify(hwWalletStore).saveKnownDevices(listOf(device.copy(customLabel = "a".repeat(50)))) @@ -1383,7 +1399,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(labelled)) val sut = createRepo() - sut.setDeviceLabel("dev1", " ") + sut.setDeviceLabel(HARDWARE_WALLET_ID, " ") verify(hwWalletStore).saveKnownDevices(listOf(labelled.copy(customLabel = null))) } @@ -1396,7 +1412,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(ble, usb)) val sut = createRepo() - sut.setDeviceLabel("usb1", "Shared") + sut.setDeviceLabel(HARDWARE_WALLET_ID, "Shared") verify(hwWalletStore).saveKnownDevices( listOf(ble.copy(customLabel = "Shared"), usb.copy(customLabel = "Shared")), @@ -1422,4 +1438,12 @@ class HwWalletRepoTest : BaseUnitTest() { any(), ) ) + + private suspend fun verifyStartWatcher(watcherId: String) { + verify(trezorRepo).startWatcher(eq(watcherId), any(), any(), any(), anyOrNull(), any(), any()) + } + + private suspend fun verifyNoStartWatcher(watcherId: String) { + verify(trezorRepo, never()).startWatcher(eq(watcherId), any(), any(), any(), anyOrNull(), any(), any()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 8ce451bae..a43e82f97 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -761,8 +761,10 @@ class TrezorRepoTest : BaseUnitTest() { @Test fun `connect preserves stored xpubs when account xpub refresh is partial`() = test { + // Re-reading an account of the same wallet yields the same key; a different one would + // be another identity on the device, not a refresh of this one. val previousXpubs = mapOf( - "nativeSegwit" to "old-native-xpub", + "nativeSegwit" to "native-xpub", "taproot" to "old-taproot-xpub", ) val nativeSegwitPath = "m/84'/1'/0'" @@ -780,7 +782,7 @@ class TrezorRepoTest : BaseUnitTest() { ).thenAnswer { val path = it.getArgument(0) if (path == nativeSegwitPath) { - mockPublicKeyResponse(xpub = "new-native-xpub", path = nativeSegwitPath) + mockPublicKeyResponse(xpub = "native-xpub", path = nativeSegwitPath) } else { throw AppError("xpub failed") } @@ -795,7 +797,7 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(captor.capture()) assertEquals( mapOf( - "nativeSegwit" to "new-native-xpub", + "nativeSegwit" to "native-xpub", "taproot" to "old-taproot-xpub", ), captor.firstValue.single().xpubs, diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index 8c6c0f280..a4d3d84c2 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -268,8 +268,9 @@ class HwConnectViewModelTest : BaseUnitTest() { val connectedFeatures = features(model = "Safe 3") whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) sut.onConnectClick() + wallets.value = persistentListOf(hwWallet("dev1", name = "Trezor Safe 3", balance = 0uL)) sut.onLabelChange("My Cold Wallet") - whenever(hwWalletRepo.setDeviceLabel("dev1", "My Cold Wallet")).thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.setDeviceLabel("wallet-dev1", "My Cold Wallet")).thenReturn(Result.success(Unit)) sut.effects.test { sut.onFinishClick() @@ -277,7 +278,7 @@ class HwConnectViewModelTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } - verify(hwWalletRepo).setDeviceLabel("dev1", "My Cold Wallet") + verify(hwWalletRepo).setDeviceLabel("wallet-dev1", "My Cold Wallet") } private suspend fun givenDeviceFound() { @@ -308,15 +309,20 @@ class HwConnectViewModelTest : BaseUnitTest() { return features } - private fun hwWallet(id: String, name: String, balance: ULong) = HwWallet( - id = id, + private fun hwWallet( + deviceId: String, + name: String, + balance: ULong, + walletId: String = "wallet-$deviceId", + ) = HwWallet( + id = walletId, name = name, model = null, transportType = TransportType.BLUETOOTH, isConnected = true, balanceSats = balance, activities = persistentListOf(), - deviceIds = persistentSetOf(id), + deviceIds = persistentSetOf(deviceId), ) private companion object { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 7b6c796b1..d0241769e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -116,7 +116,6 @@ class TransferViewModelTest : BaseUnitTest() { whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) whenever(context.getString(any())).thenReturn("") whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) - whenever { hwWalletRepo.getWalletId(DEVICE_ID) }.thenReturn(Result.success(HARDWARE_WALLET_ID)) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -255,7 +254,7 @@ class TransferViewModelTest : BaseUnitTest() { fun `updateHwLimits sources the available amount from the hardware account balance`() = test { // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)) .thenReturn( Result.success( HwFundingAccount.Trezor( @@ -270,7 +269,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - sut.updateHwLimits(DEVICE_ID) + sut.updateHwLimits(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) @@ -279,7 +278,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `updateHwLimits reserves fallback fee when fee rate lookup fails`() = test { blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) - whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)) .thenReturn( Result.success( HwFundingAccount.Trezor( @@ -295,7 +294,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - sut.updateHwLimits(DEVICE_ID) + sut.updateHwLimits(HARDWARE_WALLET_ID) advanceUntilIdle() val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * Defaults.fallbackFeePercent).toULong() @@ -315,12 +314,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - sut.updateHwFundingFeeEstimate(order, DEVICE_ID) + sut.updateHwFundingFeeEstimate(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) verify(hwWalletRepo).composeFundingTransaction( - eq(DEVICE_ID), + eq(HARDWARE_WALLET_ID), eq(order.payment?.onchain?.address.orEmpty()), eq(order.feeSat), eq(FEE_RATE), @@ -365,13 +364,13 @@ class TransferViewModelTest : BaseUnitTest() { sut.onConfirmAmount(OPTION_MAX_CLIENT_BALANCE.toLong()) advanceUntilIdle() - sut.updateHwFundingFeeEstimate(orderA, DEVICE_ID) + sut.updateHwFundingFeeEstimate(orderA, HARDWARE_WALLET_ID) runCurrent() sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) advanceUntilIdle() - sut.updateHwFundingFeeEstimate(orderB, DEVICE_ID) + sut.updateHwFundingFeeEstimate(orderB, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) @@ -569,25 +568,25 @@ class TransferViewModelTest : BaseUnitTest() { ) val signed = signedFunding(funding) whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(any(), any())).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) verify(hwWalletRepo).composeFundingTransaction( - eq(DEVICE_ID), + eq(HARDWARE_WALLET_ID), eq(order.payment?.onchain?.address.orEmpty()), eq(order.feeSat), eq(FEE_RATE), ) - verify(hwWalletRepo).signFunding(eq(DEVICE_ID), eq(funding)) + verify(hwWalletRepo).signFunding(eq(HARDWARE_WALLET_ID), eq(funding)) verify(hwWalletRepo).broadcastFunding(signed) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(transferRepo).createTransfer( @@ -607,7 +606,7 @@ class TransferViewModelTest : BaseUnitTest() { eq(FEE_RATE), eq(HARDWARE_WALLET_ID), ) - verify(hwWalletRepo).ensureConnected(DEVICE_ID) + verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) } @Test @@ -628,8 +627,8 @@ class TransferViewModelTest : BaseUnitTest() { ) val signed = signedFunding(funding, feeRate = FALLBACK_FEE_RATE) whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) .thenReturn(Result.failure(AppError("fee unavailable"))) @@ -637,12 +636,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) verify(hwWalletRepo).composeFundingTransaction( - eq(DEVICE_ID), + eq(HARDWARE_WALLET_ID), eq(order.payment?.onchain?.address.orEmpty()), eq(order.feeSat), eq(FALLBACK_FEE_RATE), @@ -653,15 +652,15 @@ class TransferViewModelTest : BaseUnitTest() { fun `onTransferToSpendingHwConfirm aborts when hardware reconnect fails`() = test { val order = previewBtOrder() whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.failure(AppError("no device"))) - whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) + whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(false) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() - verify(hwWalletRepo).ensureConnected(DEVICE_ID) + verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) verify(hwWalletRepo, never()).broadcastFunding(any()) @@ -671,10 +670,10 @@ class TransferViewModelTest : BaseUnitTest() { fun `cancelHardwareTransfer stops an in-flight hardware transfer`() = test { val order = previewBtOrder() val connectResult = CompletableDeferred>() - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)).doSuspendableAnswer { connectResult.await() } - whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)).doSuspendableAnswer { connectResult.await() } + whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) runCurrent() assertEquals(true, sut.spendingUiState.value.isSigning) @@ -683,7 +682,7 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(false, sut.spendingUiState.value.isSigning) assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) verify(hwWalletRepo, never()).broadcastFunding(any()) @@ -695,14 +694,14 @@ class TransferViewModelTest : BaseUnitTest() { 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(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.failure(AppError("no device"))) - whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(true) + whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(true) whenever(context.getString(R.string.hardware__connect_title)).thenReturn(CONNECT_TITLE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -724,18 +723,18 @@ class TransferViewModelTest : BaseUnitTest() { satsPerVByte = FEE_RATE, ) whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(any(), any())).thenReturn(Result.failure(timeout)) - whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() - verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -752,7 +751,7 @@ class TransferViewModelTest : BaseUnitTest() { totalSpent = order.feeSat + MINING_FEE, satsPerVByte = FEE_RATE, ) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) @@ -761,7 +760,7 @@ class TransferViewModelTest : BaseUnitTest() { delay(Long.MAX_VALUE) Result.success(signedFunding(funding)) } - whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) val viewModel = TransferViewModel( context = context, @@ -776,13 +775,13 @@ class TransferViewModelTest : BaseUnitTest() { boltzService = boltzService, ) - viewModel.onTransferToSpendingHwConfirm(order, DEVICE_ID) + viewModel.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) runCurrent() advanceTimeBy(120.seconds.inWholeMilliseconds + 1) runCurrent() advanceUntilIdle() - verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) verify(cacheStore, never()).addPaidOrder(any(), any()) } finally { Dispatchers.resetMain() @@ -800,15 +799,15 @@ class TransferViewModelTest : BaseUnitTest() { satsPerVByte = FEE_RATE, ) whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(any(), any())) .thenReturn(Result.failure(TrezorException.UserCancelled())) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() verify(cacheStore, never()).addPaidOrder(any(), any()) @@ -827,8 +826,8 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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)) @@ -837,7 +836,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -853,8 +852,8 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) @@ -864,7 +863,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)) .thenReturn(RECONNECT_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -882,8 +881,8 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) @@ -891,7 +890,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -910,13 +909,13 @@ class TransferViewModelTest : BaseUnitTest() { 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(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -932,10 +931,10 @@ class TransferViewModelTest : BaseUnitTest() { 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(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.failure(TrezorException.UserCancelled())) - whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) + whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(false) whenever( context.getString(R.string.lightning__transfer_hw__reconnect_error_title) ).thenReturn("reconnect title") @@ -943,7 +942,7 @@ class TransferViewModelTest : BaseUnitTest() { context.getString(R.string.lightning__transfer_hw__reconnect_error_description) ).thenReturn("reconnect body") - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -971,12 +970,12 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)) .thenReturn( Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed"))), @@ -985,7 +984,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -993,14 +992,14 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) verify(cacheStore, never()).addPaidOrder(any(), any()) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo, times(1)).ensureConnected(DEVICE_ID) + verify(hwWalletRepo, times(1)).ensureConnected(HARDWARE_WALLET_ID) verify(hwWalletRepo, times(1)).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(1)).signFunding(HARDWARE_WALLET_ID, funding) verify(hwWalletRepo, times(2)).broadcastFunding(signed) verify(cacheStore).addPaidOrder(order.id, TXID) } @@ -1016,17 +1015,17 @@ class TransferViewModelTest : BaseUnitTest() { satsPerVByte = FEE_RATE, ) val signed = signedFunding(funding) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)) .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() order = order.copy( @@ -1034,12 +1033,12 @@ class TransferViewModelTest : BaseUnitTest() { onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), ), ) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() - verify(hwWalletRepo, times(2)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(2)).signFunding(HARDWARE_WALLET_ID, funding) verify(hwWalletRepo).composeFundingTransaction( - DEVICE_ID, + HARDWARE_WALLET_ID, "bc1qnewdestination", order.feeSat, FEE_RATE, @@ -1072,14 +1071,14 @@ class TransferViewModelTest : BaseUnitTest() { } } } - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).doSuspendableAnswer { broadcastResult.await() } - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) runCurrent() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1119,11 +1118,11 @@ class TransferViewModelTest : BaseUnitTest() { feeRate = FEE_RATE, totalSpent = order.feeSat + MINING_FEE, ) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) var bookkeepingAttempts = 0 whenever(cacheStore.addPaidOrder(order.id, TXID)).thenAnswer { @@ -1131,7 +1130,7 @@ class TransferViewModelTest : BaseUnitTest() { Unit } - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1147,11 +1146,11 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), ) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) - verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(1)).signFunding(HARDWARE_WALLET_ID, funding) verify(hwWalletRepo, times(2)).broadcastFunding(signed) verify(cacheStore, times(2)).addPaidOrder(order.id, TXID) verify(transferRepo).createPendingToSpendingActivity( @@ -1178,17 +1177,17 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } whenever(hwWalletRepo.wallets) - .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(timeout)) whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1209,14 +1208,14 @@ class TransferViewModelTest : BaseUnitTest() { satsPerVByte = FEE_RATE, ) val signed = signedFunding(funding) - whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(AppError("invalid transaction"))) - sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1504,15 +1503,15 @@ class TransferViewModelTest : BaseUnitTest() { totalSpent = funding.totalSpent, ) - private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( - id = deviceId, + private fun hwWallet(walletId: String, connected: Boolean) = HwWallet( + id = walletId, name = "Trezor", model = "Safe 3", transportType = TransportType.USB, isConnected = connected, balanceSats = 0uL, activities = persistentListOf(), - deviceIds = persistentSetOf(deviceId), + deviceIds = persistentSetOf("dev1"), ) private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( @@ -1576,7 +1575,6 @@ class TransferViewModelTest : BaseUnitTest() { const val NETWORK_FEE = 2_112uL const val SERVICE_FEE = 286uL const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE - const val DEVICE_ID = "dev1" const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." const val CONNECTION_ISSUE_TITLE = "Internet Connectivity Issues" const val CONNECTION_ISSUE_DESCRIPTION = "Please check your connection." From 57b1c75486dc709519c8e5f7ed23ec5437d7c1b5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 11:35:29 -0300 Subject: [PATCH 02/31] test: cover hw multi-wallet identities --- .../bitkit/repositories/HwWalletRepoTest.kt | 92 ++++++++++++++++++- .../to/bitkit/repositories/TrezorRepoTest.kt | 86 +++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 8d896a6eb..c8a294d64 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -54,6 +54,7 @@ class HwWalletRepoTest : BaseUnitTest() { private companion object { const val HARDWARE_WALLET_ID = "hardware-wallet" + const val HIDDEN_WALLET_ID = "hidden-wallet" } private val trezorRepo = mock() @@ -78,6 +79,13 @@ class HwWalletRepoTest : BaseUnitTest() { walletId = HARDWARE_WALLET_ID, ) + /** A passphrase wallet of the same physical device: same transport id, own keys and identity. */ + private val hiddenWallet = device.copy( + xpubs = mapOf("nativeSegwit" to "zpubHidden"), + walletId = HIDDEN_WALLET_ID, + passphraseProtected = true, + ) + @Before fun setUp() { storeData = MutableStateFlow(HwWalletData(knownDevices = listOf(device))) @@ -865,6 +873,88 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, wallet.isConnected) } + @Test + fun `lists a passphrase wallet as its own tile on the same device`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + + val sut = createRepo() + + val wallets = sut.wallets.value + assertEquals(listOf(HARDWARE_WALLET_ID, HIDDEN_WALLET_ID), wallets.map { it.id }) + assertEquals(listOf(false, true), wallets.map { it.passphraseProtected }) + assertEquals(listOf(setOf("dev1"), setOf("dev1")), wallets.map { it.deviceIds }) + verifyStartWatcher("$HARDWARE_WALLET_ID|nativeSegwit") + verifyStartWatcher("$HIDDEN_WALLET_ID|nativeSegwit") + } + + @Test + fun `counts the balance of each identity on the device separately`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + val sut = createRepo() + + watcherEvents.emit( + "$HARDWARE_WALLET_ID|nativeSegwit" to transactionsChanged(total = 100uL), + ) + watcherEvents.emit( + "$HIDDEN_WALLET_ID|nativeSegwit" to transactionsChanged(total = 40uL), + ) + + assertEquals(listOf(100uL, 40uL), sut.wallets.value.map { it.balanceSats }) + assertEquals(140uL, sut.totalSats.value) + } + + @Test + fun `marks only the identity holding the session as connected`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + + val sut = createRepo() + + assertEquals(listOf(false, true), sut.wallets.value.map { it.isConnected }) + } + + @Test + fun `removeDevice forgets only the requested identity of the device`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet), listOf(device)) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + val result = sut.removeDevice(HIDDEN_WALLET_ID) + + assertTrue(result.isSuccess) + verify(trezorRepo).forgetDevice("dev1", "zpubHidden") + verify(trezorRepo).stopWatcher("$HIDDEN_WALLET_ID|nativeSegwit") + verify(trezorRepo, never()).stopWatcher("$HARDWARE_WALLET_ID|nativeSegwit") + verify(activityRepo).deleteForWallet(HIDDEN_WALLET_ID) + verify(activityRepo, never()).deleteForWallet(HARDWARE_WALLET_ID) + } + + @Test + fun `funding account resolves the requested identity on a shared device`() = test { + storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + wheneverStartWatcher().thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + watcherEvents.emit( + "$HIDDEN_WALLET_ID|nativeSegwit" to transactionsChanged(total = 40uL), + ) + + val account = sut.getFundingAccount(HIDDEN_WALLET_ID).getOrThrow() + assertEquals("zpubHidden", account.xpub) + assertEquals(40uL, account.balanceSats) + } + @Test fun `keeps a stale watcher until stopping it succeeds`() = test { storeData.value = HwWalletData( @@ -1315,7 +1405,7 @@ class HwWalletRepoTest : BaseUnitTest() { private fun transactionsChanged( total: ULong, - activities: List, + activities: List = emptyList(), ) = WatcherEvent.TransactionsChanged( balance = walletBalance(total), activities = activities, diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index a43e82f97..a3d8cbdf2 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -192,6 +192,7 @@ class TrezorRepoTest : BaseUnitTest() { xpubs: Map = emptyMap(), customLabel: String? = null, walletId: String = "wallet-id", + passphraseProtected: Boolean = false, ) = KnownDevice( id = id, name = name, @@ -203,6 +204,7 @@ class TrezorRepoTest : BaseUnitTest() { xpubs = xpubs, customLabel = customLabel, walletId = walletId, + passphraseProtected = passphraseProtected, ) // region initialize @@ -759,6 +761,55 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } + @Test + fun `connect adds a passphrase wallet next to the standard one on the same device`() = test { + val standard = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "standard-native-xpub"), + customLabel = "Savings", + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Hidden("secret")) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val saved = captor.firstValue + assertEquals(2, saved.size) + assertEquals(standard, saved.first()) + val hidden = saved.last() + assertEquals(DEVICE_ID, hidden.id) + assertTrue(hidden.passphraseProtected) + assertEquals("Savings", standard.customLabel) + assertNull(hidden.customLabel) + assertTrue(hidden.xpubs.values.none { it in standard.xpubs.values }) + } + + @Test + fun `connect keeps the standard wallet unprotected when its keys are re-read`() = test { + val standard = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'")) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val saved = captor.firstValue.single() + assertFalse(saved.passphraseProtected) + } + @Test fun `connect preserves stored xpubs when account xpub refresh is partial`() = test { // Re-reading an account of the same wallet yields the same key; a different one would @@ -1788,6 +1839,41 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(listOf(otherDevice)) } + @Test + fun `forgetDevice keeps the device paired while another identity remains`() = test { + val standardXpubs = mapOf("nativeSegwit" to "standard-native-xpub") + val hiddenXpubs = mapOf("nativeSegwit" to "hidden-native-xpub") + val standard = mockKnownDevice(xpubs = standardXpubs) + val hidden = mockKnownDevice(xpubs = hiddenXpubs, passphraseProtected = true) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard, hidden)) + sut = createSut() + + val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(hiddenXpubs)) + + assertTrue(result.isSuccess) + assertEquals(listOf(standard), sut.state.value.knownDevices) + verify(hwWalletStore).saveKnownDevices(listOf(standard)) + verify(trezorTransport, never()).clearDeviceCredential(any()) + verify(trezorService, never()).clearCredentials(any()) + } + + @Test + fun `forgetDevice clears credentials once the last identity is gone`() = test { + val standardXpubs = mapOf("nativeSegwit" to "standard-native-xpub") + val standard = mockKnownDevice(xpubs = standardXpubs) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard)) + sut = createSut() + + val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(standardXpubs)) + + assertTrue(result.isSuccess) + verify(hwWalletStore).saveKnownDevices(emptyList()) + verify(trezorTransport).clearDeviceCredential(DEVICE_ID) + verify(trezorService).clearCredentials(DEVICE_ID) + } + + private fun walletKeyOf(xpubs: Map) = xpubs.values.sorted().joinToString() + // endregion // region initial state From 4d9faff7961929bcbc3b243a9d764b0e74a1bf84 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 13:19:16 -0300 Subject: [PATCH 03/31] feat: add Trezor passphrase wallet pairing --- .../to/bitkit/repositories/HwWalletRepo.kt | 25 +++ .../ui/sheets/hardware/HardwareSheet.kt | 29 +++ .../ui/sheets/hardware/HwConnectViewModel.kt | 78 +++++++- .../ui/sheets/hardware/HwPairedSheet.kt | 84 ++++++++- .../hardware/HwPassphrasePairedSheet.kt | 53 ++++++ .../ui/sheets/hardware/HwPassphraseSheet.kt | 172 ++++++++++++++++++ app/src/main/res/values/strings.xml | 8 + .../bitkit/repositories/HwWalletRepoTest.kt | 32 ++++ .../sheets/hardware/HwConnectViewModelTest.kt | 84 +++++++++ 9 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphrasePairedSheet.kt create mode 100644 app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 4806568a6..b37ff2097 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -56,6 +56,7 @@ import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType +import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -184,6 +185,27 @@ class HwWalletRepo @Inject constructor( return trezorRepo.connect(deviceId) } + /** + * Opens the passphrase (hidden) wallet of an already paired device and watches it as its own + * identity, returning its wallet id. The passphrase is bound to a fresh Trezor session and is + * never persisted; re-entering it is what makes the wallet reachable again. + * + * Re-entering a passphrase that is already watched updates that entry rather than adding a + * second one, and reports [HwPassphraseAlreadyAddedError] so the UI can say so. + */ + suspend fun connectWithPassphrase(deviceId: String, passphrase: String): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val watchedWalletIds = hwWalletStore.loadKnownDevices().mapNotNull { it.resolvedWalletId() }.toSet() + trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, passphrase).getOrThrow() + val walletId = requireNotNull(trezorRepo.state.value.connectedWalletId()) { + "Could not read the accounts of the passphrase wallet from device '$deviceId'" + } + if (walletId in watchedWalletIds) throw HwPassphraseAlreadyAddedError() + walletId + } + } + /** Reconnects a known paired wallet so its session is live for on-device signing. */ suspend fun reconnect( walletId: String, @@ -735,6 +757,9 @@ fun resolveHwWalletName(label: String?, model: String?, customLabel: String? = n private val KnownDevice.displayName: String get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel) +/** The entered passphrase resolves to a wallet Bitkit already watches. */ +class HwPassphraseAlreadyAddedError : AppError("Passphrase wallet already added") + private data class HwWatcherData( val walletId: String, val addressType: String, diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt index dbb727f72..22a3ebaa2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt @@ -179,6 +179,26 @@ fun HardwareSheet( HwPairedSheet( uiState = uiState, onLabelChange = viewModel::onLabelChange, + onPassphrase = viewModel::onPassphraseClick, + onFinish = viewModel::onFinishClick, + ) + } + composableWithDefaultTransitions { + HwPassphraseSheet( + uiState = uiState, + onPassphraseChange = viewModel::onPassphraseChange, + onBack = { + viewModel.onPassphraseBack() + navController.popBackStack() + }, + onContinue = viewModel::onPassphraseSubmit, + ) + } + composableWithDefaultTransitions { + HwPassphrasePairedSheet( + uiState = uiState, + onLabelChange = viewModel::onLabelChange, + onPassphrase = viewModel::onPassphraseClick, onFinish = viewModel::onFinishClick, ) } @@ -233,6 +253,9 @@ private fun ConnectEffectHandler( HardwareRoute.PairCode(requestId = effect.requestId), ) HwConnectEffect.NavigateToPaired -> navController.navigateTo(HardwareRoute.Paired) + HwConnectEffect.NavigateToPassphrase -> navController.navigateTo(HardwareRoute.Passphrase) + HwConnectEffect.NavigateToPassphrasePaired -> + navController.navigateTo(HardwareRoute.PassphrasePaired) HwConnectEffect.Dismiss -> appViewModel.hideSheet() HwConnectEffect.Finish -> { appViewModel.hideSheet() @@ -263,6 +286,12 @@ sealed interface HardwareRoute { @Serializable data object Paired : InternalOnly + @Serializable + data object Passphrase : InternalOnly + + @Serializable + data object PassphrasePaired : InternalOnly + @Serializable data class PairCode(val requestId: Long) : InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index a21a3054c..406a15029 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -18,9 +18,12 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.models.Toast +import to.bitkit.repositories.HwPassphraseAlreadyAddedError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH import to.bitkit.repositories.resolveHwWalletName +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -31,7 +34,11 @@ import kotlin.time.Duration.Companion.seconds * [HwConnectEffect]s that the sheet collects to navigate its inner [HardwareRoute] graph. The * one-time pairing code, when the device requests it during connect, is surfaced inline by * navigating to [HardwareRoute.PairCode]. + * + * From the paired step the user can add the passphrase (hidden) wallets of the same device, each + * becoming its own watched identity with its own label and balance. */ +@Suppress("TooManyFunctions") @HiltViewModel class HwConnectViewModel @Inject constructor( private val hwWalletRepo: HwWalletRepo, @@ -155,6 +162,64 @@ class HwConnectViewModel @Inject constructor( fun onLabelChange(value: String) = _uiState.update { it.copy(labelInput = value.take(DEVICE_LABEL_MAX_LENGTH)) } + fun onPassphraseClick() { + _uiState.update { it.copy(passphraseInput = "", errorMessage = null) } + setEffect(HwConnectEffect.NavigateToPassphrase) + } + + fun onPassphraseChange(value: String) = _uiState.update { it.copy(passphraseInput = value) } + + /** Leaves the passphrase step without keeping what was typed. */ + fun onPassphraseBack() = _uiState.update { it.copy(passphraseInput = "") } + + /** + * Opens the hidden wallet the entered passphrase unlocks and watches it as its own identity. + * The passphrase is dropped from state as soon as the device answers: it lives in the Trezor + * session, never in Bitkit. + */ + fun onPassphraseSubmit() { + val state = _uiState.value + val deviceId = state.pairedDeviceId ?: return + if (state.passphraseInput.isEmpty() || connectJob?.isActive == true) return + + connectJob = viewModelScope.launch { + _uiState.update { it.copy(isSubmittingPassphrase = true, errorMessage = null) } + hwWalletRepo.connectWithPassphrase(deviceId = deviceId, passphrase = state.passphraseInput) + .onSuccess { onPassphraseWalletAdded(it) } + .onFailure { onPassphraseFailed(it) } + connectJob = null + } + } + + private fun onPassphraseWalletAdded(walletId: String) { + // The new identity has its own name and balance, so let the wallet observer prefill again. + labelInitialized = false + _uiState.update { + it.copy( + isSubmittingPassphrase = false, + passphraseInput = "", + pairedWalletId = walletId, + balanceSats = 0uL, + labelInput = "", + ) + } + setEffect(HwConnectEffect.NavigateToPassphrasePaired) + } + + private suspend fun onPassphraseFailed(error: Throwable) { + _uiState.update { it.copy(isSubmittingPassphrase = false, passphraseInput = "") } + val description = when (error) { + is HwPassphraseAlreadyAddedError -> context.getString(R.string.hardware__passphrase_duplicate) + else if error.isTrezorDeviceBusy() -> TrezorErrorPresenter.userMessage(context, error) + else -> context.getString(R.string.hardware__passphrase_error) + } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = description, + ) + } + fun onFinishClick() { val walletId = _uiState.value.pairedWalletId if (walletId == null) { @@ -239,8 +304,12 @@ class HwConnectViewModel @Inject constructor( private fun observeConnectedWallet() { viewModelScope.launch { hwWalletRepo.wallets.collect { wallets -> - val deviceId = _uiState.value.pairedDeviceId ?: return@collect - val wallet = wallets.firstOrNull { deviceId in it.deviceIds } ?: return@collect + val state = _uiState.value + val deviceId = state.pairedDeviceId ?: return@collect + // A device can hold several passphrase wallets, so prefer the identity being paired. + val wallet = state.pairedWalletId?.let { id -> wallets.firstOrNull { it.id == id } } + ?: wallets.firstOrNull { deviceId in it.deviceIds } + ?: return@collect _uiState.update { it.copy( pairedWalletId = wallet.id, @@ -265,6 +334,9 @@ data class HwConnectUiState( val pairedDeviceId: String? = null, /** Identity paired on [pairedDeviceId]; resolved once its watch-only wallet is known. */ val pairedWalletId: String? = null, + /** Held only until the device answers; the passphrase is never persisted or logged. */ + val passphraseInput: String = "", + val isSubmittingPassphrase: Boolean = false, val deviceName: String = "", val deviceModel: String = "", val balanceSats: ULong = 0uL, @@ -277,6 +349,8 @@ sealed interface HwConnectEffect { data class NavigateToFound(val deviceId: String, val deviceModel: String) : HwConnectEffect data class NavigateToPairCode(val requestId: Long) : HwConnectEffect data object NavigateToPaired : HwConnectEffect + data object NavigateToPassphrase : HwConnectEffect + data object NavigateToPassphrasePaired : HwConnectEffect data object Dismiss : HwConnectEffect data object Finish : HwConnectEffect } diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt index 1db872c63..48d2b041d 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.sheets.hardware import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,8 +19,12 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview @@ -27,6 +32,7 @@ import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.HW_ILLUSTRATION_SIZE_RATIO import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView @@ -45,30 +51,46 @@ fun HwPairedSheet( uiState: HwConnectUiState, modifier: Modifier = Modifier, onLabelChange: (String) -> Unit = {}, + onPassphrase: () -> Unit = {}, onFinish: () -> Unit = {}, ) { - Content( + HwPairedContent( uiState = uiState, + header = stringResource(R.string.hardware__paired_header).withAccent(accentColor = Colors.Blue), + text = stringResource(R.string.hardware__paired_text), + screenTag = "HardwareWalletPairedScreen", onLabelChange = onLabelChange, + onPassphrase = onPassphrase, onFinish = onFinish, modifier = modifier ) } +/** + * Paired step shared by the standard wallet and the passphrase wallet found afterwards: both + * confirm the watched balance and its Bitkit-side label, and both can add another passphrase + * wallet from the same device before finishing. + */ @Composable -private fun Content( +internal fun HwPairedContent( uiState: HwConnectUiState, + header: AnnotatedString, + text: String, + screenTag: String, modifier: Modifier = Modifier, onLabelChange: (String) -> Unit = {}, + onPassphrase: () -> Unit = {}, onFinish: () -> Unit = {}, ) { + val hazeState = rememberHazeState() + Column( modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() .imePadding() - .testTag("HardwareWalletPairedScreen") + .testTag(screenTag) ) { SheetTopBar(titleText = stringResource(R.string.hardware__paired_title)) Column( @@ -76,9 +98,9 @@ private fun Content( .fillMaxWidth() .padding(horizontal = 32.dp) ) { - Display(stringResource(R.string.hardware__paired_header).withAccent(accentColor = Colors.Blue)) + Display(header) VerticalSpacer(8.dp) - BodyM(stringResource(R.string.hardware__paired_text), color = Colors.White64) + BodyM(text, color = Colors.White64) VerticalSpacer(32.dp) Row(modifier = Modifier.fillMaxWidth()) { WalletBalanceView( @@ -99,6 +121,8 @@ private fun Content( .testTag("HardwareWalletLabelInput") ) } + // The buttons sit over the coins, so the illustration is the haze source and must stay a + // sibling of the blurred button: haze cannot blur an ancestor. BoxWithConstraints( modifier = Modifier .fillMaxWidth() @@ -112,16 +136,47 @@ private fun Content( .align(Alignment.BottomCenter) .width(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) .aspectRatio(COINS_ASPECT_RATIO) + .hazeSource(hazeState) + ) + HwPairedButtons( + hazeState = hazeState, + onPassphrase = onPassphrase, + onFinish = onFinish, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 32.dp) ) } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun HwPairedButtons( + hazeState: HazeState, + modifier: Modifier = Modifier, + onPassphrase: () -> Unit = {}, + onFinish: () -> Unit = {}, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.hardware__passphrase_button), + onClick = onPassphrase, + hazeState = hazeState, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletPairedPassphrase") + ) PrimaryButton( text = stringResource(R.string.hardware__paired_finish), onClick = onFinish, modifier = Modifier - .padding(horizontal = 32.dp) + .weight(1f) .testTag("HardwareWalletPairedFinish") ) - VerticalSpacer(16.dp) } } @@ -130,7 +185,7 @@ private fun Content( private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content( + HwPairedSheet( uiState = HwConnectUiState( deviceName = "Trezor Safe 3", balanceSats = 10_562_411uL, @@ -141,3 +196,16 @@ private fun Preview() { } } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewEmpty() { + AppThemeSurface { + BottomSheetPreview { + HwPairedSheet( + uiState = HwConnectUiState(deviceName = "Trezor Safe 3"), + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphrasePairedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphrasePairedSheet.kt new file mode 100644 index 000000000..ca42d2a1d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphrasePairedSheet.kt @@ -0,0 +1,53 @@ +package to.bitkit.ui.sheets.hardware + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import to.bitkit.R +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** + * Confirms the passphrase wallet Bitkit just started watching. It is the paired step of a separate + * identity, so it carries its own funds label and can loop back for another passphrase wallet. + */ +@Composable +fun HwPassphrasePairedSheet( + uiState: HwConnectUiState, + modifier: Modifier = Modifier, + onLabelChange: (String) -> Unit = {}, + onPassphrase: () -> Unit = {}, + onFinish: () -> Unit = {}, +) { + HwPairedContent( + uiState = uiState, + header = stringResource(R.string.hardware__passphrase_paired_header).withAccent(accentColor = Colors.Blue), + text = stringResource(R.string.hardware__passphrase_paired_text), + screenTag = "HardwareWalletPassphrasePairedScreen", + onLabelChange = onLabelChange, + onPassphrase = onPassphrase, + onFinish = onFinish, + modifier = modifier + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + HwPassphrasePairedSheet( + uiState = HwConnectUiState( + deviceName = "Trezor Safe 3", + balanceSats = 5_214_983uL, + labelInput = "Trezor Safe 3", + ), + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt new file mode 100644 index 000000000..3a42a857a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt @@ -0,0 +1,172 @@ +package to.bitkit.ui.sheets.hardware + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.HW_ILLUSTRATION_SIZE_RATIO +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** + * Optional step of the connect flow: the passphrase that unlocks a hidden wallet on the paired + * device. Bitkit binds it to a fresh Trezor session to read that wallet's accounts and never + * stores it, so it is asked for again whenever the session has to be rebuilt. + */ +@Composable +fun HwPassphraseSheet( + uiState: HwConnectUiState, + modifier: Modifier = Modifier, + onPassphraseChange: (String) -> Unit = {}, + onBack: () -> Unit = {}, + onContinue: () -> Unit = {}, +) { + Content( + uiState = uiState, + onPassphraseChange = onPassphraseChange, + onBack = onBack, + onContinue = onContinue, + modifier = modifier + ) +} + +@Composable +private fun Content( + uiState: HwConnectUiState, + modifier: Modifier = Modifier, + onPassphraseChange: (String) -> Unit = {}, + onBack: () -> Unit = {}, + onContinue: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .imePadding() + .testTag("HardwareWalletPassphraseScreen") + ) { + SheetTopBar(titleText = stringResource(R.string.hardware__passphrase_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__passphrase_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__passphrase_text), color = Colors.White64) + VerticalSpacer(32.dp) + TextInput( + value = uiState.passphraseInput, + onValueChange = onPassphraseChange, + singleLine = true, + // A passphrase is case- and character-exact: never let the keyboard alter it. + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + ), + modifier = Modifier + .fillMaxWidth() + .testTag("HardwareWalletPassphraseInput") + ) + } + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds() + ) { + Image( + painter = painterResource(R.drawable.shield), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__back), + onClick = onBack, + enabled = !uiState.isSubmittingPassphrase, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletPassphraseBack") + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onContinue, + enabled = uiState.passphraseInput.isNotEmpty(), + isLoading = uiState.isSubmittingPassphrase, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletPassphraseContinue") + ) + } + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = HwConnectUiState(passphraseInput = "satoshirulestheworld"), + modifier = Modifier.sheetHeight() + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewSubmitting() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = HwConnectUiState( + passphraseInput = "satoshirulestheworld", + isSubmittingPassphrase = true, + ), + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1af6e9845..b81bfaab6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,6 +192,14 @@ Device Connected Enter the 6-digit code shown on your hardware device. Pair Device + Passphrase + You are already watching this passphrase wallet. + Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again. + Enter <accent>passphrase</accent> + Passphrase <accent>funds found</accent> + Bitkit found funds behind a passphrase, and added these to your wallet balance. + If you have funds protected by a passphrase, enter it below to add these funds to your wallet balance as well. + Passphrase Remove %1$s Don\'t worry, your funds are safe and your coins won\'t be deleted. Bitkit will simply stop displaying the amounts in the wallet. Remove %1$s diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index c8a294d64..d5c4b800d 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -42,6 +42,7 @@ import to.bitkit.models.TransportType import to.bitkit.models.WalletScope import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType +import to.bitkit.services.TrezorWalletMode import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -938,6 +939,37 @@ class HwWalletRepoTest : BaseUnitTest() { verify(activityRepo, never()).deleteForWallet(HARDWARE_WALLET_ID) } + @Test + fun `connectWithPassphrase opens the hidden wallet and returns its identity`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + .thenReturn(Result.success(mock())) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + val sut = createRepo() + + val result = sut.connectWithPassphrase(deviceId = "dev1", passphrase = "secret") + + assertEquals(HIDDEN_WALLET_ID, result.getOrThrow()) + verify(trezorRepo).setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") + } + + @Test + fun `connectWithPassphrase reports a passphrase wallet that is already watched`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + .thenReturn(Result.success(mock())) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + val sut = createRepo() + + val result = sut.connectWithPassphrase(deviceId = "dev1", passphrase = "secret") + + assertTrue(result.exceptionOrNull() is HwPassphraseAlreadyAddedError) + } + @Test fun `funding account resolves the requested identity on a shared device`() = test { storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index a4d3d84c2..d8e2c9f76 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -11,6 +11,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test @@ -21,9 +22,11 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.HwWallet import to.bitkit.models.TransportType +import to.bitkit.repositories.HwPassphraseAlreadyAddedError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.TrezorState import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -48,6 +51,7 @@ class HwConnectViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_ERROR) whenever(context.getString(R.string.hardware__search_error)).thenReturn(SEARCH_ERROR) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) + whenever(context.getString(R.string.common__error)).thenReturn(ERROR_TITLE) sut = HwConnectViewModel( hwWalletRepo = hwWalletRepo, context = context, @@ -281,6 +285,84 @@ class HwConnectViewModelTest : BaseUnitTest() { verify(hwWalletRepo).setDeviceLabel("wallet-dev1", "My Cold Wallet") } + @Test + fun `onPassphraseSubmit watches the hidden wallet and advances to its paired step`() = test { + givenPairedDevice() + whenever(hwWalletRepo.connectWithPassphrase("dev1", "secret")) + .thenReturn(Result.success("hidden-wallet")) + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + + sut.effects.test { + sut.onPassphraseSubmit() + assertEquals(HwConnectEffect.NavigateToPassphrasePaired, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals("hidden-wallet", sut.uiState.value.pairedWalletId) + assertEquals("", sut.uiState.value.passphraseInput) + assertFalse(sut.uiState.value.isSubmittingPassphrase) + } + + @Test + fun `paired step follows the identity being paired on a device holding several wallets`() = test { + givenPairedDevice() + whenever(hwWalletRepo.connectWithPassphrase("dev1", "secret")) + .thenReturn(Result.success("hidden-wallet")) + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + sut.onPassphraseSubmit() + runCurrent() + + wallets.value = persistentListOf( + hwWallet("dev1", name = "Trezor Safe 3", balance = 10uL), + hwWallet("dev1", name = "Hidden Safe 3", balance = 40uL, walletId = "hidden-wallet"), + ) + + assertEquals("Hidden Safe 3", sut.uiState.value.deviceName) + assertEquals(40uL, sut.uiState.value.balanceSats) + assertEquals("Hidden Safe 3", sut.uiState.value.labelInput) + } + + @Test + fun `onPassphraseSubmit keeps the passphrase out of state when the wallet is already watched`() = test { + givenPairedDevice() + whenever(context.getString(R.string.hardware__passphrase_duplicate)).thenReturn(DUPLICATE_ERROR) + whenever(hwWalletRepo.connectWithPassphrase("dev1", "secret")) + .thenReturn(Result.failure(HwPassphraseAlreadyAddedError())) + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + + ToastEventBus.events.test { + sut.onPassphraseSubmit() + runCurrent() + assertEquals(DUPLICATE_ERROR, awaitItem().description) + cancelAndIgnoreRemainingEvents() + } + + assertEquals("", sut.uiState.value.passphraseInput) + assertEquals(null, sut.uiState.value.pairedWalletId) + } + + @Test + fun `resetState drops the entered passphrase`() = test { + givenPairedDevice() + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + + sut.resetState() + + assertEquals("", sut.uiState.value.passphraseInput) + } + + private suspend fun TestScope.givenPairedDevice() { + givenDeviceFound() + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + sut.onConnectClick() + runCurrent() + } + private suspend fun givenDeviceFound() { deviceState.value = TrezorState(nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3"))) whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList())) @@ -327,6 +409,8 @@ class HwConnectViewModelTest : BaseUnitTest() { private companion object { const val CONNECT_ERROR = "Could not connect" + const val DUPLICATE_ERROR = "Already watching this passphrase wallet" + const val ERROR_TITLE = "Error" const val SEARCH_ERROR = "Could not search" const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." } From dd794a0396180f372926522c7385d65d88d958da Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 13:40:11 -0300 Subject: [PATCH 04/31] feat: verify passphrase before hw signing --- .../to/bitkit/repositories/HwWalletRepo.kt | 40 ++++ .../hardware/HwPassphrasePromptSheet.kt | 176 ++++++++++++++++++ .../transfer/hardware/SpendingHwSignScreen.kt | 8 + .../to/bitkit/viewmodels/TransferViewModel.kt | 50 +++++ app/src/main/res/values/strings.xml | 2 + .../bitkit/repositories/HwWalletRepoTest.kt | 66 +++++++ .../viewmodels/TransferViewModelTest.kt | 82 ++++++++ 7 files changed, 424 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index b37ff2097..4887d6aaf 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -222,6 +222,43 @@ class HwWalletRepo @Inject constructor( } } + /** + * Whether reaching [walletId] needs the passphrase again. The device only holds one hidden + * wallet open at a time and forgets the passphrase with the session, so a passphrase wallet + * that is not the live session cannot be reconnected — or signed with — without it. + */ + suspend fun needsPassphrase(walletId: String): Boolean = withContext(ioDispatcher) { + val devices = devicesForWallet(walletId) + devices.any { it.passphraseProtected } && trezorRepo.state.value.connectedWalletId() != walletId + } + + /** + * Reopens a watched passphrase wallet for signing. A wrong passphrase is not rejected by the + * device — it silently derives a different wallet — so the reopened session is only accepted + * when its accounts resolve back to [walletId]; anything else is torn down again and reported + * as [HwPassphraseMismatchError] rather than signing from the wrong wallet. + */ + suspend fun reconnectWithPassphrase(walletId: String, passphrase: String): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val deviceId = transportDeviceId(walletId) + val watchedBefore = hwWalletStore.loadKnownDevices().mapNotNull { it.resolvedWalletId() }.toSet() + trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, passphrase).getOrThrow() + val opened = trezorRepo.state.value.connectedWalletId() + if (opened == walletId) return@runSuspendCatching + + Logger.warn("Rejected hardware session for '$walletId': opened wallet '$opened'", context = TAG) + // Reading the accounts of the wrong wallet already stored it; a mistyped passphrase + // must not leave a stray watch-only wallet behind. + if (opened != null && opened !in watchedBefore) { + removeDevice(opened) + .onFailure { Logger.warn("Failed to drop unwatched wallet '$opened'", it, context = TAG) } + } + trezorRepo.disconnectStaleSession(deviceId) + throw HwPassphraseMismatchError() + } + } + suspend fun isKnownBluetoothDevice(walletId: String): Boolean = withContext(ioDispatcher) { val deviceId = transportDeviceIdOrNull(walletId) ?: return@withContext false trezorRepo.isKnownBluetoothDevice(deviceId) @@ -760,6 +797,9 @@ private val KnownDevice.displayName: String /** The entered passphrase resolves to a wallet Bitkit already watches. */ class HwPassphraseAlreadyAddedError : AppError("Passphrase wallet already added") +/** The entered passphrase opened a different wallet than the one being signed from. */ +class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet") + private data class HwWatcherData( val walletId: String, val addressType: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt new file mode 100644 index 000000000..c92da707c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -0,0 +1,176 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +/** + * Asks for the passphrase of the hidden wallet a transfer signs from. Bitkit never stores it, so + * it is needed again whenever the Trezor session that held it is gone. What is typed stays local + * to this sheet and is handed straight to the device session. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HwPassphrasePromptSheet( + isVerifying: Boolean, + onSubmit: (String) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val dismissKeyboard = { + focusManager.clearFocus() + keyboardController?.hide() + } + + fun closeSheet() { + scope.launch { + dismissKeyboard() + sheetState.hide() + onDismiss() + } + } + + BottomSheet( + onDismissRequest = { closeSheet() }, + sheetState = sheetState, + modifier = Modifier.imePadding() + ) { + Content( + isVerifying = isVerifying, + onSubmit = { + dismissKeyboard() + onSubmit(it) + }, + onCancel = { closeSheet() }, + modifier = Modifier.sheetHeight(SheetSize.MEDIUM, isModal = true) + ) + } +} + +@Composable +private fun Content( + isVerifying: Boolean, + modifier: Modifier = Modifier, + onSubmit: (String) -> Unit = {}, + onCancel: () -> Unit = {}, +) { + var passphrase by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .imePadding() + .testTag("HwTransferPassphraseSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.hardware__passphrase_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Display(stringResource(R.string.hardware__passphrase_header).withAccent(accentColor = Colors.Blue)) + VerticalSpacer(8.dp) + BodyM(stringResource(R.string.hardware__passphrase_sign_text), color = Colors.White64) + VerticalSpacer(32.dp) + TextInput( + value = passphrase, + onValueChange = { passphrase = it }, + singleLine = true, + // A passphrase is case- and character-exact: never let the keyboard alter it. + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .testTag("HwTransferPassphraseInput") + ) + FillHeight() + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + enabled = !isVerifying, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseCancel") + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { onSubmit(passphrase) }, + enabled = passphrase.isNotEmpty(), + isLoading = isVerifying, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseContinue") + ) + } + VerticalSpacer(16.dp) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content(isVerifying = false) + } +} 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 754c6b46f..c83313b97 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 @@ -76,6 +76,14 @@ fun SpendingHwSignScreen( onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, walletId) }, ) + + if (state.isHwPassphraseRequired) { + HwPassphrasePromptSheet( + isVerifying = state.isVerifyingHwPassphrase, + onSubmit = { viewModel.onHwPassphraseSubmit(order, walletId, it) }, + onDismiss = viewModel::onHwPassphraseDismiss, + ) + } } @Composable diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 11a45d634..11f14bc6b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -60,6 +60,7 @@ import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -815,6 +816,13 @@ class TransferViewModel @Inject constructor( activeHwTransferWalletId = walletId hwTransferSignJob = viewModelScope.launch { + // A hidden wallet whose session is gone can only be reopened with its passphrase, and + // the device would otherwise sign from whichever wallet the current session holds. + if (hwWalletRepo.needsPassphrase(walletId)) { + _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } + hwTransferSignJob = null + return@launch + } _spendingUiState.update { it.copy(isSigning = true) } try { val address = order.payment?.onchain?.address.orEmpty() @@ -851,6 +859,45 @@ class TransferViewModel @Inject constructor( } } + /** + * Reopens the hidden wallet with the entered passphrase and, once its accounts prove it is the + * wallet the transfer is for, continues into signing. The passphrase is passed straight through + * to the device session; it is never kept in UI state. + */ + fun onHwPassphraseSubmit(order: IBtOrder, walletId: String, passphrase: String) { + if (passphrase.isEmpty() || hwTransferSignJob?.isActive == true) return + + hwTransferSignJob = viewModelScope.launch { + _spendingUiState.update { it.copy(isVerifyingHwPassphrase = true) } + val result = hwWalletRepo.reconnectWithPassphrase(walletId = walletId, passphrase = passphrase) + _spendingUiState.update { it.copy(isVerifyingHwPassphrase = false) } + hwTransferSignJob = null + result + .onSuccess { + _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } + onTransferToSpendingHwConfirm(order, walletId) + } + .onFailure { handleHardwarePassphraseFailure(it, walletId) } + } + } + + fun onHwPassphraseDismiss() { + _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } + } + + private suspend fun handleHardwarePassphraseFailure(e: Throwable, walletId: String) { + if (e is HwPassphraseMismatchError) { + Logger.warn("Rejected wrong passphrase for hardware wallet '$walletId'", context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__passphrase_mismatch), + ) + return + } + handleHardwareTransferFailure(e, walletId) + } + private suspend fun signAndBroadcastHardwareFunding( order: IBtOrder, walletId: String, @@ -1578,6 +1625,9 @@ data class TransferToSpendingUiState( val isLoading: Boolean = false, val isSigning: Boolean = false, val hasPendingHwBroadcast: Boolean = false, + /** The hidden wallet needs its passphrase before the device can sign for it. */ + val isHwPassphraseRequired: Boolean = false, + val isVerifyingHwPassphrase: Boolean = false, val hwMiningFeeSats: ULong = 0uL, /** Real on-chain mining fee for soft-wallet confirm (iOS transactionFee). */ val miningFeeSats: ULong = 0uL, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b81bfaab6..bf4b4e680 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,8 @@ You are already watching this passphrase wallet. Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again. Enter <accent>passphrase</accent> + That passphrase opens a different wallet. Enter the one you paired this wallet with. + Enter the passphrase of this wallet so your hardware device can sign the transfer. Passphrase <accent>funds found</accent> Bitkit found funds behind a passphrase, and added these to your wallet balance. If you have funds protected by a passphrase, enter it below to add these funds to your wallet balance as well. diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index d5c4b800d..7a25a4820 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -46,6 +46,7 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -970,6 +971,71 @@ class HwWalletRepoTest : BaseUnitTest() { assertTrue(result.exceptionOrNull() is HwPassphraseAlreadyAddedError) } + @Test + fun `needsPassphrase only while the hidden wallet is not the live session`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + val sut = createRepo() + + assertTrue(sut.needsPassphrase(HIDDEN_WALLET_ID)) + assertFalse(sut.needsPassphrase(HARDWARE_WALLET_ID)) + + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + + assertFalse(sut.needsPassphrase(HIDDEN_WALLET_ID)) + } + + @Test + fun `reconnectWithPassphrase accepts a session that reopens the same wallet`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + .thenAnswer { + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + Result.success(mock()) + } + val sut = createRepo() + + val result = sut.reconnectWithPassphrase(HIDDEN_WALLET_ID, "secret") + + assertTrue(result.isSuccess) + verify(trezorRepo, never()).disconnectStaleSession(any()) + } + + @Test + fun `reconnectWithPassphrase drops the wallet a wrong passphrase opened and refuses to sign`() = test { + val strayWallet = device.copy( + xpubs = mapOf("nativeSegwit" to "zpubStray"), + walletId = "stray-wallet", + passphraseProtected = true, + ) + var stored = listOf(device, hiddenWallet) + whenever { hwWalletStore.loadKnownDevices() }.thenAnswer { stored } + whenever { trezorRepo.stopWatcher(any()) }.thenReturn(Result.success(Unit)) + whenever { trezorRepo.forgetDevice(any(), anyOrNull()) }.thenAnswer { + stored = stored.filterNot { it.walletId == "stray-wallet" } + Result.success(Unit) + } + // A wrong passphrase derives another wallet, which reading its accounts already stored. + whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "wrong") } + .thenAnswer { + stored = stored + strayWallet + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = "stray-wallet"), + ) + Result.success(mock()) + } + val sut = createRepo() + + val result = sut.reconnectWithPassphrase(HIDDEN_WALLET_ID, "wrong") + + assertTrue(result.exceptionOrNull() is HwPassphraseMismatchError) + verify(trezorRepo).forgetDevice("dev1", "zpubStray") + verify(trezorRepo).disconnectStaleSession("dev1") + } + @Test fun `funding account resolves the requested identity on a shared device`() = test { storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index d0241769e..18c209a9d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -67,6 +67,7 @@ import to.bitkit.models.TransportType import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -116,6 +117,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) whenever(context.getString(any())).thenReturn("") whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -609,6 +611,85 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) } + @Test + fun `onTransferToSpendingHwConfirm asks for the passphrase when the hidden wallet session is gone`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) + + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) + assertFalse(sut.spendingUiState.value.isSigning) + verify(hwWalletRepo, never()).ensureConnected(any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + } + + @Test + fun `onHwPassphraseSubmit signs once the reopened wallet matches`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true, false) + whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } + .thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_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.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn( + Result.success( + HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + ) + ) + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + advanceUntilIdle() + + sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "secret") + advanceUntilIdle() + + assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) + verify(hwWalletRepo).reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") + verify(hwWalletRepo).signFunding(eq(HARDWARE_WALLET_ID), eq(funding)) + } + + @Test + fun `onHwPassphraseSubmit does not sign when the passphrase opens another wallet`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) + whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "wrong") } + .thenReturn(Result.failure(HwPassphraseMismatchError())) + whenever(context.getString(R.string.hardware__passphrase_mismatch)).thenReturn(PASSPHRASE_MISMATCH) + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + + sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "wrong") + advanceUntilIdle() + toastJob.cancel() + + assertEquals(PASSPHRASE_MISMATCH, toasts.single().description) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + @Test fun `onTransferToSpendingHwConfirm composes with fallback fee rate when fee lookup fails`() = test { val order = previewBtOrder() @@ -1581,6 +1662,7 @@ class TransferViewModelTest : BaseUnitTest() { const val CONNECT_TITLE = "Connect Device" const val CONNECT_DESCRIPTION = "Check the hardware device and try again." const val HARDWARE_WALLET_ID = "hardware-wallet" + const val PASSPHRASE_MISMATCH = "That passphrase opens a different wallet." const val RECONNECT_TITLE = "Reconnect Hardware Device" const val RECONNECT_DESCRIPTION = "Please reconnect your hardware device." const val XPUB = "zpub-test" From 9cbb34ebf0ac9bdd41d56a36d664f4f7a59318f2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 13:43:43 -0300 Subject: [PATCH 05/31] feat: add screenshot protection for passphrase inputs --- .../ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt | 3 +++ .../java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt index c92da707c..e7d49e968 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -40,6 +40,7 @@ import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.effects.BlockScreenshots import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface @@ -100,6 +101,8 @@ private fun Content( onSubmit: (String) -> Unit = {}, onCancel: () -> Unit = {}, ) { + BlockScreenshots() + var passphrase by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt index 3a42a857a..d4a66d551 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt @@ -32,6 +32,7 @@ import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.effects.BlockScreenshots import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface @@ -68,6 +69,8 @@ private fun Content( onBack: () -> Unit = {}, onContinue: () -> Unit = {}, ) { + BlockScreenshots() + Column( modifier = modifier .fillMaxSize() From 0a9e5b9272f291e81cabd25c277879ff43014a6c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 13:46:01 -0300 Subject: [PATCH 06/31] feat: add shield image --- .../hardware/HwPassphrasePromptSheet.kt | 73 ++++++++++++------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt index e7d49e968..0b4f791b8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -1,6 +1,8 @@ package to.bitkit.ui.screens.transfer.hardware +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState @@ -18,12 +21,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -33,7 +39,7 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheet import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HW_ILLUSTRATION_SIZE_RATIO import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SheetSize @@ -89,7 +95,7 @@ fun HwPassphrasePromptSheet( onSubmit(it) }, onCancel = { closeSheet() }, - modifier = Modifier.sheetHeight(SheetSize.MEDIUM, isModal = true) + modifier = Modifier.sheetHeight(SheetSize.LARGE, isModal = true) ) } } @@ -142,31 +148,46 @@ private fun Content( .focusRequester(focusRequester) .testTag("HwTransferPassphraseInput") ) - FillHeight() - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - enabled = !isVerifying, - modifier = Modifier - .weight(1f) - .testTag("HwTransferPassphraseCancel") - ) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = { onSubmit(passphrase) }, - enabled = passphrase.isNotEmpty(), - isLoading = isVerifying, - modifier = Modifier - .weight(1f) - .testTag("HwTransferPassphraseContinue") - ) - } - VerticalSpacer(16.dp) } + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds() + ) { + Image( + painter = painterResource(R.drawable.shield), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + enabled = !isVerifying, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseCancel") + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { onSubmit(passphrase) }, + enabled = passphrase.isNotEmpty(), + isLoading = isVerifying, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseContinue") + ) + } + VerticalSpacer(16.dp) } } From d676acc6fd13e653d786ca13f039db517edc2890 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 13:54:09 -0300 Subject: [PATCH 07/31] test: add passphrase wallet journeys --- changelog.d/next/1060.added.md | 1 + journeys/hardware-wallet/README.md | 37 +++++++--- .../hardware-wallet/passphrase-duplicate.xml | 40 +++++++++++ .../hardware-wallet/passphrase-pairing.xml | 66 +++++++++++++++++ .../passphrase-settings-remove.xml | 46 ++++++++++++ .../passphrase-transfer-to-spending.xml | 72 +++++++++++++++++++ 6 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 changelog.d/next/1060.added.md create mode 100644 journeys/hardware-wallet/passphrase-duplicate.xml create mode 100644 journeys/hardware-wallet/passphrase-pairing.xml create mode 100644 journeys/hardware-wallet/passphrase-settings-remove.xml create mode 100644 journeys/hardware-wallet/passphrase-transfer-to-spending.xml diff --git a/changelog.d/next/1060.added.md b/changelog.d/next/1060.added.md new file mode 100644 index 000000000..54ed99bef --- /dev/null +++ b/changelog.d/next/1060.added.md @@ -0,0 +1 @@ +Passphrase-protected (hidden) Trezor wallets can now be paired from the connect flow, each appearing as its own watch-only balance with its own label, activity and removal, and asking for its passphrase again when a transfer needs signing. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index bf20c328e..248acf806 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -23,10 +23,12 @@ The Bridge transport is HTTP (`TrezorBridgeTransport` → `http://127.0.0.1:2132 is still needed to verify the "Open with Bitkit" path that opens the Found Device sheet for an unpaired Trezor. - **Not simulated**: kernel/libusbhost behavior, USB enumeration timing, permission - grants, the OS app picker, BLE runtime/settings recovery, THP one-time pairing code - (the inline Pair Device step), and passphrase/hidden-wallet selection. Those need a physical - device or a dedicated emulator scenario; passphrase coverage is tracked in - synonymdev/bitkit-android#1030. + grants, the OS app picker, BLE runtime/settings recovery, and the THP one-time pairing code + (the inline Pair Device step). Those need a physical device. +- **Simulated with extra setup**: passphrase (hidden) wallets. The emulator derives a separate + account set per passphrase, but the device must be set up with passphrase protection enabled — + see the prerequisites below. Host-side entry is the only mode Bitkit ships, so nothing has to + be typed on the emulated device. Journey steps that start with `adb:` are device commands the runner executes verbatim instead of UI interactions. @@ -42,6 +44,10 @@ instead of UI interactions. ```sh ../bitkit-docker/scripts/trezor-emulator start ``` + The `passphrase-*` journeys additionally need passphrase protection enabled on the device: + ```sh + TREZOR_PASSPHRASE_PROTECTION=true ../bitkit-docker/scripts/trezor-emulator start + ``` 3. For a physical phone, reverse the Bridge port and install with Bridge enabled: ```sh ../bitkit-docker/scripts/trezor-emulator adb @@ -55,7 +61,9 @@ instead of UI interactions. Run in this order — `connect-home-tile.xml` pairs the emulator that the later journeys rely on, `suggestion-intro-sheet.xml`, `connect-flow.xml` and `settings-hardware-wallets.xml` each end by re-pairing after a forget, and `detail-overview.xml` runs last because its final -Remove step forgets the device. +Remove step forgets the device. The `passphrase-*` journeys run as a block after +`connect-home-tile.xml`, in the order listed: `passphrase-pairing.xml` pairs the hidden wallet +the other three rely on, and `passphrase-settings-remove.xml` removes it again. | Journey | Covers | | - | - | @@ -70,6 +78,10 @@ Remove step forgets the device. | `transfer-to-spending.xml` | Happy-path transfer plus one scoped hardware Transfer activity | | `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | | `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | +| `passphrase-pairing.xml` | Passphrase button on Paired → Enter Passphrase → Passphrase Funds Found; second home tile, own label, no passphrase in logs | +| `passphrase-duplicate.xml` | Re-entering a watched passphrase reports "already added" and adds no tile | +| `passphrase-settings-remove.xml` | Per-identity settings row, rename and delete; removing the hidden wallet keeps the device paired | +| `passphrase-transfer-to-spending.xml` | Signs with the live session, re-prompts after the session is dropped, refuses a wrong passphrase | Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, `HardwareWalletIntroCancel`, `HardwareWalletIntroContinue`, @@ -83,6 +95,12 @@ Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, Settings rename testTags: `HardwareWalletsScreen`, `RenameHardwareWalletInput`, and `RenameHardwareWalletSave`. +Passphrase testTags: `HardwareWalletPairedPassphrase`, `HardwareWalletPassphraseScreen`, +`HardwareWalletPassphraseInput`, `HardwareWalletPassphraseBack`, +`HardwareWalletPassphraseContinue`, `HardwareWalletPassphrasePairedScreen`, and on the transfer +sign screen `HwTransferPassphraseSheet`, `HwTransferPassphraseInput`, +`HwTransferPassphraseCancel`, `HwTransferPassphraseContinue`. + The current Connect Hardware sheet starts USB discovery immediately after Continue. BLE is included only once Android nearby-devices permission is granted and Bluetooth is enabled. The sheet has no internal back navigation; Android back dismisses the sheet. @@ -93,9 +111,12 @@ should show its Bluetooth access recovery dialog with an Open Settings action; t path is better validated on a physical device because the Bridge path can still find devices without BLE. -Current journeys pair the standard wallet. Hidden/passphrase wallet behavior is intentionally -not asserted here yet; it needs explicit UX and identity-scoping coverage as described in -synonymdev/bitkit-android#1030. +A physical device holds one hidden wallet open at a time, and Bitkit never stores the +passphrase, so the `passphrase-*` journeys assert both halves of that: the tile, label, +settings row, activity scope and removal are per identity, while signing reuses the live +session and asks again once it is gone. They also grep the app log and datastore to prove the +passphrase is never written anywhere — note `BlockScreenshots` is a no-op in debug builds, so +the passphrase steps remain screenshottable while journeys run. To exercise the received-money sheet (not covered by a journey because it needs an out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, e.g. diff --git a/journeys/hardware-wallet/passphrase-duplicate.xml b/journeys/hardware-wallet/passphrase-duplicate.xml new file mode 100644 index 000000000..b40168080 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-duplicate.xml @@ -0,0 +1,40 @@ + + + Re-entering a passphrase that is already watched must not add a second tile for the same + wallet: Bitkit reports it as already added and the hardware tile count stays unchanged. + Requires the emulator started with passphrase protection enabled and the hidden wallet from + passphrase-pairing.xml already paired. + + + + Launch the Bitkit app and go to the wallet home screen + + + Count the hardware wallet tiles shown beneath the SAVINGS and SPENDING tiles and remember the number + + + Open the menu, navigate to Settings, then General, then Payments, then tap the "Hardware Wallets" row + + + Tap the "Add Hardware Wallet" button (testTag "AddHardwareWallet"), tap "Continue" (testTag "HardwareWalletIntroContinue"), wait for the Found Device step and tap "Connect" (testTag "HardwareWalletFoundConnect") + + + On the "Device Connected" step tap "Passphrase" (testTag "HardwareWalletPairedPassphrase") + + + Type the already paired passphrase "bitkit-hidden" into the passphrase field (testTag "HardwareWalletPassphraseInput") and tap "Continue" (testTag "HardwareWalletPassphraseContinue") + + + Confirm the passphrase prompt on the Bridge emulator if the device asks for it + + + Verify an error toast reports the passphrase wallet is already being watched, and that the sheet stays on the Passphrase step (testTag "HardwareWalletPassphraseScreen") with an empty input + + + Dismiss the sheet and return to the wallet home screen + + + Verify the number of hardware wallet tiles is unchanged from the count noted at the start + + + diff --git a/journeys/hardware-wallet/passphrase-pairing.xml b/journeys/hardware-wallet/passphrase-pairing.xml new file mode 100644 index 000000000..a758ac0d0 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-pairing.xml @@ -0,0 +1,66 @@ + + + Adds a passphrase (hidden) wallet of an already paired Trezor: from the Paired step the + Passphrase button opens Enter Passphrase, and entering one watches the wallet it unlocks as a + separate identity with its own funds label. Verifies the home screen then shows two hardware + tiles and counts both in the headline balance. Requires the emulator started with passphrase + protection enabled (TREZOR_PASSPHRASE_PROTECTION=true ./scripts/trezor-emulator start) and a + paired Bridge emulator (run connect-home-tile.xml first). + + + + Launch the Bitkit app, open the menu, navigate to Settings, then General, then Payments, then tap the "Hardware Wallets" row + + + Note the paired-device count shown, then tap the "Add Hardware Wallet" button (testTag "AddHardwareWallet") + + + Tap "Continue" (testTag "HardwareWalletIntroContinue") and wait for the Found Device step (testTag "HardwareWalletFoundScreen"), then tap "Connect" (testTag "HardwareWalletFoundConnect") + + + Verify the sheet reaches the "Device Connected" step (testTag "HardwareWalletPairedScreen"), showing both a "Passphrase" button (testTag "HardwareWalletPairedPassphrase") and a "Finish" button (testTag "HardwareWalletPairedFinish") + + + Tap "Passphrase" (testTag "HardwareWalletPairedPassphrase") + + + Verify the Passphrase step opens (testTag "HardwareWalletPassphraseScreen") headed "Enter passphrase", showing the shield illustration, and that "Continue" (testTag "HardwareWalletPassphraseContinue") is disabled while the input is empty + + + Type "bitkit-hidden" into the passphrase field (testTag "HardwareWalletPassphraseInput"), then tap "Continue" (testTag "HardwareWalletPassphraseContinue") + + + Confirm the passphrase prompt on the Bridge emulator if the device asks for it + + + Verify the sheet advances to the passphrase paired step (testTag "HardwareWalletPassphrasePairedScreen") headed "Passphrase funds found", showing a balance and an editable "Label Funds" field prefilled with the device name + + + Clear the "Label Funds" field (testTag "HardwareWalletLabelInput") and type "Hidden Trezor" + + + Tap "Finish" (testTag "HardwareWalletPairedFinish") and verify the sheet closes + + + Navigate to the wallet home screen and verify two hardware wallet tiles are shown beneath the SAVINGS and SPENDING tiles, one of them labelled "Hidden Trezor" + + + Verify the headline total balance is at least the sum of both hardware tile balances + + + Tap the "Hidden Trezor" tile and verify its hardware wallet detail screen opens (testTag "HardwareWalletScreen") titled "Hidden Trezor" + + + adb: adb shell "run-as to.bitkit.dev sh -c 'grep -rl bitkit-hidden files/logs/ files/datastore/ || echo NO_PASSPHRASE_LEAK'" + + + Verify the previous command printed NO_PASSPHRASE_LEAK: the passphrase must never reach the app logs or the datastore, only the device session + + + adb: adb shell "run-as to.bitkit.dev sh -c 'grep -iE \"error|exception\" $(ls -t files/logs/*.log | head -1) | tail -20 || true'" + + + Verify the previous command reported no Trezor connect, session or watcher errors while the hidden wallet was paired + + + diff --git a/journeys/hardware-wallet/passphrase-settings-remove.xml b/journeys/hardware-wallet/passphrase-settings-remove.xml new file mode 100644 index 000000000..d6d58400e --- /dev/null +++ b/journeys/hardware-wallet/passphrase-settings-remove.xml @@ -0,0 +1,46 @@ + + + Verifies that a passphrase wallet is a first-class identity in settings: the Payments count + includes it, the Hardware Wallets screen lists it as its own row with its own rename and + delete, and removing it leaves the standard wallet of the same physical device paired and + watched. Requires the hidden wallet from passphrase-pairing.xml already paired. + + + + Launch the Bitkit app, open the menu, and navigate to Settings + + + Ensure the "General" tab is selected, scroll to the "Payments" section, and verify the "Hardware Wallets" row shows a count of at least 2 + + + Tap the "Hardware Wallets" row and verify the screen opens (testTag "HardwareWalletsScreen") listing two rows, one of them named "Hidden Trezor" + + + Verify each row shows its own balance and connection indicator, and that the two balances differ + + + Tap the "Hidden Trezor" row name to open the rename sheet, clear the input (testTag "RenameHardwareWalletInput"), type "Hidden Funds", and tap Save (testTag "RenameHardwareWalletSave") + + + Verify only the hidden wallet row was renamed to "Hidden Funds" and the standard wallet row kept its own name + + + Tap the delete (trash) icon on the "Hidden Funds" row, and confirm "Remove" in the dialog + + + Verify the Hardware Wallets screen now lists exactly one row, the standard wallet, still showing its balance + + + Navigate to the wallet home screen and verify a single hardware wallet tile remains with a non-zero balance + + + adb: adb shell "run-as to.bitkit.dev sh -c 'ls files/trezor-thp-credentials/ 2>/dev/null | wc -l'" + + + Verify the previous command reported at least one credential file: removing one identity must not unpair the physical device + + + Tap the remaining hardware wallet tile and verify its detail screen opens and lists its activity, confirming the device was not re-paired + + + diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml new file mode 100644 index 000000000..19776d8c5 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,72 @@ + + + Drives Transfer To Spending from a passphrase (hidden) wallet. While the Trezor session that + holds the passphrase is still open the transfer signs straight away; after the session is + dropped Bitkit asks for the passphrase again, and a wrong one is refused instead of signing + from whichever wallet the device happens to have open. Requires the emulator started with + passphrase protection enabled, the hidden wallet from passphrase-pairing.xml paired, and its + native-segwit account holding spendable regtest funds. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the hidden wallet tile ("Hidden Trezor") and verify its detail screen opens (testTag "HardwareWalletScreen") + + + Tap "Transfer To Spending" (testTag "HardwareTransferToSpending"), and if the first-run intro is shown tap "Get Started" + + + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter"), then "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order + + + Verify the sign screen opens (testTag "HardwareTransferSign") titled "SIGN WITH YOUR DEVICE" + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and verify no passphrase sheet appears, because the session opened at pairing still holds the hidden wallet + + + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order, and verify the transaction signed screen appears (testTag "HardwareTransferSigned") + + + Wait for the Processing Payment screen, tap "Continue Using Bitkit", and verify the app returns to the wallet home screen + + + adb: adb shell am force-stop to.bitkit.dev + + + adb: adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 + + + Once the app is back on the home screen, open the hidden wallet tile and start Transfer To Spending again, setting an amount with the "25%" quick button and continuing to the sign screen + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and verify the passphrase sheet opens (testTag "HwTransferPassphraseSheet"), because the session that held the passphrase is gone + + + Type a wrong passphrase "not-the-one" into the input (testTag "HwTransferPassphraseInput") and tap "Continue" (testTag "HwTransferPassphraseContinue") + + + Confirm the passphrase prompt on the Bridge emulator if the device asks for it + + + Verify an error toast says the passphrase opens a different wallet, that no signing prompt was shown on the emulator, and that no new transfer appears in the activity list + + + Navigate to the wallet home screen and verify the hardware tile count is unchanged: the wallet the wrong passphrase opened must not be added + + + Reopen the sign screen, tap "Open Trezor Connect", type the correct passphrase "bitkit-hidden" and tap "Continue" + + + Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator, and verify the transaction signed screen appears (testTag "HardwareTransferSigned") + + + adb: adb shell "run-as to.bitkit.dev sh -c 'grep -rl -e bitkit-hidden -e not-the-one files/logs/ files/datastore/ || echo NO_PASSPHRASE_LEAK'" + + + Verify the previous command printed NO_PASSPHRASE_LEAK: neither the correct nor the rejected passphrase may be written to the logs or the datastore + + + From cb0550a2be028bcf53e5a30097f4876dd2e972d3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 14:24:58 -0300 Subject: [PATCH 08/31] fix: shield shrink --- .../ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt | 4 ++-- .../java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt index 0b4f791b8..3017f1c85 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState @@ -160,7 +160,7 @@ private fun Content( contentDescription = null, modifier = Modifier .align(Alignment.Center) - .size(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) ) } Row( diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt index d4a66d551..8d95cd82a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -114,7 +114,7 @@ private fun Content( contentDescription = null, modifier = Modifier .align(Alignment.Center) - .size(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) ) } Row( From d833939ee13a2fe44dbf78f2b76573f507ad0919 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 14:25:16 -0300 Subject: [PATCH 09/31] fix: coin stack image padding --- .../java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt index 48d2b041d..2642370e4 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairedSheet.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -134,7 +134,7 @@ internal fun HwPairedContent( contentDescription = null, modifier = Modifier .align(Alignment.BottomCenter) - .width(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .requiredWidth(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) .aspectRatio(COINS_ASPECT_RATIO) .hazeSource(hazeState) ) @@ -144,10 +144,9 @@ internal fun HwPairedContent( onFinish = onFinish, modifier = Modifier .align(Alignment.BottomCenter) - .padding(horizontal = 32.dp) + .padding(horizontal = 32.dp, vertical = 16.dp) ) } - VerticalSpacer(16.dp) } } From f9938060dd151de651208d336330de257b39909e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 14:34:50 -0300 Subject: [PATCH 10/31] fix: prefill passphrase wallet name --- .../hardware/HwPassphrasePromptSheet.kt | 53 +++++++++--------- .../ui/sheets/hardware/HwConnectViewModel.kt | 14 +++-- .../ui/sheets/hardware/HwPassphraseSheet.kt | 54 ++++++++++--------- 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt index 3017f1c85..f175f6c62 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ui.components.BodyM @@ -109,6 +111,7 @@ private fun Content( ) { BlockScreenshots() + val hazeState = rememberHazeState() var passphrase by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } @@ -161,33 +164,35 @@ private fun Content( modifier = Modifier .align(Alignment.Center) .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .hazeSource(hazeState) ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - ) { - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - enabled = !isVerifying, + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .weight(1f) - .testTag("HwTransferPassphraseCancel") - ) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = { onSubmit(passphrase) }, - enabled = passphrase.isNotEmpty(), - isLoading = isVerifying, - modifier = Modifier - .weight(1f) - .testTag("HwTransferPassphraseContinue") - ) + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + enabled = !isVerifying, + hazeState = hazeState, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseCancel") + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { onSubmit(passphrase) }, + enabled = passphrase.isNotEmpty(), + isLoading = isVerifying, + modifier = Modifier + .weight(1f) + .testTag("HwTransferPassphraseContinue") + ) + } } - VerticalSpacer(16.dp) } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 406a15029..a01673819 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -192,15 +192,21 @@ class HwConnectViewModel @Inject constructor( } private fun onPassphraseWalletAdded(walletId: String) { - // The new identity has its own name and balance, so let the wallet observer prefill again. - labelInitialized = false + // Prefill from the new identity right away: the wallet list may have settled while it was + // being persisted, and waiting for another emission would leave the label field empty. + val wallet = hwWalletRepo.wallets.value.firstOrNull { it.id == walletId } + val name = wallet?.name ?: _uiState.value.deviceName + // Fall back to the device name until the new wallet shows up, and let that emission + // refine the prefill; once it is resolved the field is the user's to edit. + labelInitialized = wallet != null _uiState.update { it.copy( isSubmittingPassphrase = false, passphraseInput = "", pairedWalletId = walletId, - balanceSats = 0uL, - labelInput = "", + deviceName = name, + balanceSats = wallet?.balanceSats ?: 0uL, + labelInput = name, ) } setEffect(HwConnectEffect.NavigateToPassphrasePaired) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt index 8d95cd82a..5ae319289 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview @@ -71,6 +73,8 @@ private fun Content( ) { BlockScreenshots() + val hazeState = rememberHazeState() + Column( modifier = modifier .fillMaxSize() @@ -115,33 +119,35 @@ private fun Content( modifier = Modifier .align(Alignment.Center) .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .hazeSource(hazeState) ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - ) { - SecondaryButton( - text = stringResource(R.string.common__back), - onClick = onBack, - enabled = !uiState.isSubmittingPassphrase, - modifier = Modifier - .weight(1f) - .testTag("HardwareWalletPassphraseBack") - ) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinue, - enabled = uiState.passphraseInput.isNotEmpty(), - isLoading = uiState.isSubmittingPassphrase, + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .weight(1f) - .testTag("HardwareWalletPassphraseContinue") - ) + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.common__back), + onClick = onBack, + enabled = !uiState.isSubmittingPassphrase, + hazeState = hazeState, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletPassphraseBack") + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onContinue, + enabled = uiState.passphraseInput.isNotEmpty(), + isLoading = uiState.isSubmittingPassphrase, + modifier = Modifier + .weight(1f) + .testTag("HardwareWalletPassphraseContinue") + ) + } } - VerticalSpacer(16.dp) } } From c203bc5be7ccf47fb57d9171a54e6b848f229b31 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 14:35:11 -0300 Subject: [PATCH 11/31] test: update journeys passphrase flow --- journeys/hardware-wallet/passphrase-duplicate.xml | 4 +++- journeys/hardware-wallet/passphrase-pairing.xml | 4 +++- journeys/hardware-wallet/passphrase-transfer-to-spending.xml | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/journeys/hardware-wallet/passphrase-duplicate.xml b/journeys/hardware-wallet/passphrase-duplicate.xml index b40168080..e115378af 100644 --- a/journeys/hardware-wallet/passphrase-duplicate.xml +++ b/journeys/hardware-wallet/passphrase-duplicate.xml @@ -25,7 +25,9 @@ Type the already paired passphrase "bitkit-hidden" into the passphrase field (testTag "HardwareWalletPassphraseInput") and tap "Continue" (testTag "HardwareWalletPassphraseContinue") - Confirm the passphrase prompt on the Bridge emulator if the device asks for it + Confirm on the emulated device for every account the passphrase session reads: the device + prompts once per address type and the call blocks until it is acknowledged. Either tap the + emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' Verify an error toast reports the passphrase wallet is already being watched, and that the sheet stays on the Passphrase step (testTag "HardwareWalletPassphraseScreen") with an empty input diff --git a/journeys/hardware-wallet/passphrase-pairing.xml b/journeys/hardware-wallet/passphrase-pairing.xml index a758ac0d0..b89042d22 100644 --- a/journeys/hardware-wallet/passphrase-pairing.xml +++ b/journeys/hardware-wallet/passphrase-pairing.xml @@ -30,7 +30,9 @@ Type "bitkit-hidden" into the passphrase field (testTag "HardwareWalletPassphraseInput"), then tap "Continue" (testTag "HardwareWalletPassphraseContinue") - Confirm the passphrase prompt on the Bridge emulator if the device asks for it + Confirm on the emulated device for every account the passphrase session reads: the device + prompts once per address type and the call blocks until it is acknowledged. Either tap the + emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' Verify the sheet advances to the passphrase paired step (testTag "HardwareWalletPassphrasePairedScreen") headed "Passphrase funds found", showing a balance and an editable "Label Funds" field prefilled with the device name diff --git a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml index 19776d8c5..b3333bdc0 100644 --- a/journeys/hardware-wallet/passphrase-transfer-to-spending.xml +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -48,7 +48,9 @@ Type a wrong passphrase "not-the-one" into the input (testTag "HwTransferPassphraseInput") and tap "Continue" (testTag "HwTransferPassphraseContinue") - Confirm the passphrase prompt on the Bridge emulator if the device asks for it + Confirm on the emulated device for every account the passphrase session reads: the device + prompts once per address type and the call blocks until it is acknowledged. Either tap the + emulator UI or run: ../bitkit-docker/scripts/trezor-emulator send-json '{"type":"emulator-press-yes","id":1}' Verify an error toast says the passphrase opens a different wallet, that no signing prompt was shown on the emulator, and that no new transfer appears in the activity list From 8885a6fe233ff52d6a762c6a8592601a32ed5401 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 14:57:54 -0300 Subject: [PATCH 12/31] fix: TrezorBridgeTransport reused a released session id on re-acquire --- .../bitkit/services/TrezorBridgeTransport.kt | 3 ++ .../ui/sheets/hardware/HwConnectViewModel.kt | 3 ++ .../services/TrezorBridgeTransportTest.kt | 15 ++++++++ .../sheets/hardware/HwConnectViewModelTest.kt | 37 +++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 58d55d103..0a1cdbaff 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -110,6 +110,9 @@ class TrezorBridgeTransport( return runCatching { post("/release/${encode(session)}") + // The released session must not be offered as the previous one on the next acquire: + // the bridge holds none afterwards and rejects a stale id with 'wrong previous session'. + enumeratedSessions.remove(path) Logger.info("Closed Trezor Bridge device '$path'", context = TAG) TrezorTransportWriteResult(success = true, error = "", errorCode = null) }.getOrElse { diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index a01673819..76633a601 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -265,7 +265,10 @@ class HwConnectViewModel @Inject constructor( continue } _uiState.update { it.copy(errorMessage = null) } + // Unpaired devices come first; a device that is already paired is only offered so + // its passphrase wallets can be added, since discovery skips known devices. val device = hwWalletRepo.deviceState.value.nearbyDevices.firstOrNull() + ?: scanResult.getOrNull().orEmpty().firstOrNull { hwWalletRepo.hasKnownDevice(it.id) } if (device != null) { val deviceModel = resolveHwWalletName(label = null, model = device.model) _uiState.update { diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 254089fb7..9dbdbbdfb 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -90,6 +90,21 @@ class TrezorBridgeTransportTest { assertTrue(releaseCalled) } + @Test + fun `reopening after a release acquires without the stale session`() { + // Switching to a passphrase wallet closes and reopens the session; offering the released + // session id as the previous one makes the bridge answer 'wrong previous session'. + val sut = createSut() + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + assertTrue(sut.closeDevice(device.path).success) + + val reopenResult = sut.openDevice(device.path) + + assertTrue(reopenResult.success, "requests=${server.requests}") + assertEquals(2, server.requests.count { it == "POST /acquire/emulator%3A21324/null" }) + } + @Test fun `call fails when bridge device was not opened`() { val sut = createSut() diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index d8e2c9f76..56b163e4b 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -285,6 +285,43 @@ class HwConnectViewModelTest : BaseUnitTest() { verify(hwWalletRepo).setDeviceLabel("wallet-dev1", "My Cold Wallet") } + @Test + fun `offers an already paired device when discovery finds nothing new`() = test { + // Discovery skips known devices, so a paired Trezor only reaches the paired step — where + // its passphrase wallets are added — through this fallback. + val paired = deviceInfo("dev1", model = "Safe 3") + deviceState.value = TrezorState(nearbyDevices = persistentListOf()) + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(listOf(paired))) + whenever { hwWalletRepo.hasKnownDevice("dev1") }.thenReturn(true) + + sut.effects.test { + sut.onIntroContinue() + assertEquals(HwConnectEffect.NavigateToSearching, awaitItem()) + assertEquals(HwConnectEffect.NavigateToFound("dev1", "Trezor Safe 3"), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals("dev1", sut.uiState.value.foundDeviceId) + } + + @Test + fun `keeps searching when the only device found is neither new nor paired`() = test { + deviceState.value = TrezorState(nearbyDevices = persistentListOf()) + whenever(hwWalletRepo.scan(includeBluetooth = true)) + .thenReturn(Result.success(listOf(deviceInfo("other", model = "Safe 3")))) + whenever { hwWalletRepo.hasKnownDevice("other") }.thenReturn(false) + + sut.effects.test { + sut.onIntroContinue() + assertEquals(HwConnectEffect.NavigateToSearching, awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(sut.uiState.value.isSearching) + sut.resetState() + } + @Test fun `onPassphraseSubmit watches the hidden wallet and advances to its paired step`() = test { givenPairedDevice() From ade56eb1e6ddac8bb134a656c94e104d7feb617d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 15:02:05 -0300 Subject: [PATCH 13/31] chore: rename changelog fragment --- changelog.d/next/{1060.added.md => 1142.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1060.added.md => 1142.added.md} (100%) diff --git a/changelog.d/next/1060.added.md b/changelog.d/next/1142.added.md similarity index 100% rename from changelog.d/next/1060.added.md rename to changelog.d/next/1142.added.md From b95a5825681858706d7968f113ee84c2d99d640a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 08:57:27 -0300 Subject: [PATCH 14/31] refactor: replace runCatching with runSuspendCatching, limited to the scope of this branch --- .../java/to/bitkit/repositories/TrezorRepo.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 59a59f4c3..f903a4018 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -869,15 +869,18 @@ class TrezorRepo @Inject constructor( * wallet does not unpair the device for the others. */ suspend fun forgetDevice(deviceId: String, walletKey: String? = null): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { - runCatching { - trezorService.disconnect() - disconnectTransportDevice(deviceId) - }.also { - // Clear any cached host passphrase so it can't be reused - // against a different device on a later connect. + try { + runSuspendCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) + } + } finally { + // Clear any cached host passphrase so it can't be reused against a different + // device on a later connect. In a finally so a cancelled disconnect, which now + // propagates instead of being swallowed, still clears it. trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD) _state.update { it.copy(connected = null) } } @@ -892,7 +895,7 @@ class TrezorRepo @Inject constructor( val clearCredentialsResult = if (updated.none { it.id == deviceId }) { TrezorDebugLog.log("FORGET", "Clearing credentials...") trezorTransport.clearDeviceCredential(deviceId) - runCatching { trezorService.clearCredentials(deviceId) } + runSuspendCatching { trezorService.clearCredentials(deviceId) } } else { TrezorDebugLog.log("FORGET", "Keeping credentials, another wallet still uses $deviceId") Result.success(Unit) From 7117b386d5352f8f08a7e3d2df361b4941804765 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 10:35:17 -0300 Subject: [PATCH 15/31] fix: HW wallet label persistence --- .../ui/sheets/hardware/HwConnectViewModel.kt | 31 +++++++++++++- .../sheets/hardware/HwConnectViewModelTest.kt | 41 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 76633a601..e6f6761f4 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -24,6 +24,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH import to.bitkit.repositories.resolveHwWalletName import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -45,6 +46,8 @@ class HwConnectViewModel @Inject constructor( @ApplicationContext private val context: Context, ) : ViewModel() { companion object { + private const val TAG = "HwConnectViewModel" + /** Delay between scan attempts while searching for a nearby device. */ private val SCAN_INTERVAL = 2.seconds @@ -160,9 +163,20 @@ class HwConnectViewModel @Inject constructor( _uiState.update { it.copy(isConnecting = false) } } - fun onLabelChange(value: String) = _uiState.update { it.copy(labelInput = value.take(DEVICE_LABEL_MAX_LENGTH)) } + fun onLabelChange(value: String) { + // Once the user types, the field is theirs: a wallet emission arriving late (the store + // publishes a newly watched identity asynchronously) must not overwrite what they entered. + labelInitialized = true + _uiState.update { it.copy(labelInput = value.take(DEVICE_LABEL_MAX_LENGTH)) } + } fun onPassphraseClick() { + // Each identity is labelled on its own paired step, so persist the one being left before + // the next passphrase wallet takes over the field. + val state = _uiState.value + state.pairedWalletId?.let { walletId -> + viewModelScope.launch { persistLabel(walletId, state.labelInput) } + } _uiState.update { it.copy(passphraseInput = "", errorMessage = null) } setEffect(HwConnectEffect.NavigateToPassphrase) } @@ -191,6 +205,18 @@ class HwConnectViewModel @Inject constructor( } } + private suspend fun persistLabel(walletId: String, label: String) { + hwWalletRepo.setDeviceLabel(walletId, label) + .onFailure { + Logger.error("Failed to label hardware wallet '$walletId'", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__rename_error), + ) + } + } + private fun onPassphraseWalletAdded(walletId: String) { // Prefill from the new identity right away: the wallet list may have settled while it was // being persisted, and waiting for another emission would leave the label field empty. @@ -232,8 +258,9 @@ class HwConnectViewModel @Inject constructor( setEffect(HwConnectEffect.Dismiss) return } + val label = _uiState.value.labelInput viewModelScope.launch { - hwWalletRepo.setDeviceLabel(walletId, _uiState.value.labelInput) + persistLabel(walletId, label) setEffect(HwConnectEffect.Finish) } } diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index 56b163e4b..30b938b42 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -361,6 +361,47 @@ class HwConnectViewModelTest : BaseUnitTest() { assertEquals("Hidden Safe 3", sut.uiState.value.labelInput) } + @Test + fun `persists the label of the wallet left behind when adding a passphrase wallet`() = test { + // Each identity is named on its own paired step, so the standard wallet's name must be + // kept when the user moves on to add a passphrase wallet instead of finishing. + givenPairedDevice() + wallets.value = persistentListOf(hwWallet("dev1", name = "Trezor Safe 3", balance = 10uL)) + whenever(hwWalletRepo.setDeviceLabel("wallet-dev1", "My Savings")).thenReturn(Result.success(Unit)) + sut.onLabelChange("My Savings") + + sut.onPassphraseClick() + runCurrent() + + verify(hwWalletRepo).setDeviceLabel("wallet-dev1", "My Savings") + } + + @Test + fun `keeps the typed label when the new wallet is published afterwards`() = test { + // The store publishes a newly watched identity asynchronously, so its emission can land + // after the user has already named it; the entered name must survive and be persisted. + givenPairedDevice() + whenever(hwWalletRepo.connectWithPassphrase("dev1", "secret")) + .thenReturn(Result.success("hidden-wallet")) + whenever(hwWalletRepo.setDeviceLabel("hidden-wallet", "My Hidden")).thenReturn(Result.success(Unit)) + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + sut.onPassphraseSubmit() + runCurrent() + + sut.onLabelChange("My Hidden") + wallets.value = persistentListOf( + hwWallet("dev1", name = "Trezor Safe 3", balance = 0uL, walletId = "hidden-wallet"), + ) + + assertEquals("My Hidden", sut.uiState.value.labelInput) + + sut.onFinishClick() + runCurrent() + + verify(hwWalletRepo).setDeviceLabel("hidden-wallet", "My Hidden") + } + @Test fun `onPassphraseSubmit keeps the passphrase out of state when the wallet is already watched`() = test { givenPairedDevice() From 7e4c8e339c1d88e8d65d1216aedc65a02b01b4fe Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 10:57:49 -0300 Subject: [PATCH 16/31] fix: resolve HW wallet name from wallet id --- .../ui/sheets/hardware/HwConnectViewModel.kt | 16 +++++++++--- .../sheets/hardware/HwConnectViewModelTest.kt | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index e6f6761f4..6eb498d47 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -315,17 +315,23 @@ class HwConnectViewModel @Inject constructor( } private fun onConnected(deviceId: String, features: TrezorFeatures) { - val name = resolveHwWalletName(label = features.label, model = features.model) + // The device may hold several identities, so take the one this session opened rather than + // any wallet sharing its transport id, and show the name it was already saved under. + val walletId = hwWalletRepo.deviceState.value.connectedWalletId() + val wallet = walletId?.let { id -> hwWalletRepo.wallets.value.firstOrNull { it.id == id } } + val name = wallet?.name ?: resolveHwWalletName(label = features.label, model = features.model) + labelInitialized = wallet != null _uiState.update { it.copy( isConnecting = false, pairedDeviceId = deviceId, + pairedWalletId = walletId, deviceName = name, - labelInput = if (labelInitialized) it.labelInput else name, + balanceSats = wallet?.balanceSats ?: it.balanceSats, + labelInput = name, errorMessage = null, ) } - labelInitialized = true setEffect(HwConnectEffect.NavigateToPaired) } @@ -342,8 +348,10 @@ class HwConnectViewModel @Inject constructor( hwWalletRepo.wallets.collect { wallets -> val state = _uiState.value val deviceId = state.pairedDeviceId ?: return@collect - // A device can hold several passphrase wallets, so prefer the identity being paired. + // A device can hold several passphrase wallets, so prefer the identity being + // paired, then the one holding the session; sharing a transport id proves nothing. val wallet = state.pairedWalletId?.let { id -> wallets.firstOrNull { it.id == id } } + ?: wallets.firstOrNull { deviceId in it.deviceIds && it.isConnected } ?: wallets.firstOrNull { deviceId in it.deviceIds } ?: return@collect _uiState.update { diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index 30b938b42..20da449aa 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -22,6 +22,7 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.HwWallet import to.bitkit.models.TransportType +import to.bitkit.repositories.ConnectedTrezorDevice import to.bitkit.repositories.HwPassphraseAlreadyAddedError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.TrezorState @@ -361,6 +362,31 @@ class HwConnectViewModelTest : BaseUnitTest() { assertEquals("Hidden Safe 3", sut.uiState.value.labelInput) } + @Test + fun `reconnecting a device with several wallets shows the session identity and its saved name`() = test { + // Both identities share the transport id, so only the live session says which one was + // opened, and the paired step must show the name that identity was saved under. + val hidden = hwWallet("dev1", name = "Pass A", balance = 10_000uL, walletId = "hidden-wallet") + val standard = hwWallet("dev1", name = "No Pass", balance = 27uL, walletId = "standard-wallet") + wallets.value = persistentListOf(hidden, standard) + deviceState.value = TrezorState( + nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")), + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = "standard-wallet"), + ) + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList())) + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + sut.onIntroContinue() + runCurrent() + + sut.onConnectClick() + runCurrent() + + assertEquals("standard-wallet", sut.uiState.value.pairedWalletId) + assertEquals("No Pass", sut.uiState.value.deviceName) + assertEquals("No Pass", sut.uiState.value.labelInput) + } + @Test fun `persists the label of the wallet left behind when adding a passphrase wallet`() = test { // Each identity is named on its own paired step, so the standard wallet's name must be From 37b22b2260a4dad5c15f69ce4664978dca92117f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 11:19:17 -0300 Subject: [PATCH 17/31] fix: use stored devices as source of true when deleting --- .../java/to/bitkit/repositories/TrezorRepo.kt | 7 ++++-- .../to/bitkit/repositories/TrezorRepoTest.kt | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index f903a4018..0b7906624 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -887,8 +887,11 @@ class TrezorRepo @Inject constructor( } else { Result.success(Unit) } - val knownDevices = (_state.value.knownDevices + loadKnownDevices()) - .distinctBy { it.id to it.walletKey } + // The store is the source of truth here: labels are written straight to it, so a + // cached entry taking precedence would rewrite the wallets left behind without theirs. + val stored = loadKnownDevices() + val storedEntries = stored.map { it.id to it.walletKey }.toSet() + val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } val updated = knownDevices.filterNot { it.id == deviceId && (walletKey == null || it.walletKey == walletKey) } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index a3d8cbdf2..aaaeff4ac 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -1857,6 +1857,29 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).clearCredentials(any()) } + @Test + fun `forgetDevice keeps the stored label of the identity left behind`() = test { + // Labels are written straight to the store, so the cached device list can be out of date; + // rewriting from it would drop the name the user gave the wallet that stays paired. + val removedXpubs = mapOf("nativeSegwit" to "removed-native-xpub") + val keptXpubs = mapOf("nativeSegwit" to "kept-native-xpub") + val removed = mockKnownDevice(xpubs = removedXpubs, passphraseProtected = true) + val keptWhenCached = mockKnownDevice(xpubs = keptXpubs, passphraseProtected = true) + val keptWhenStored = keptWhenCached.copy(customLabel = "Pass B") + whenever(hwWalletStore.loadKnownDevices()) + .thenReturn(listOf(removed, keptWhenCached)) + .thenReturn(listOf(removed, keptWhenStored)) + sut = createSut() + sut.initialize() + + val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(removedXpubs)) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(listOf(keptWhenStored), captor.lastValue) + } + @Test fun `forgetDevice clears credentials once the last identity is gone`() = test { val standardXpubs = mapOf("nativeSegwit" to "standard-native-xpub") From 6c2e2d990afc79e6229e053262e4790c5b1044ff Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 11:31:55 -0300 Subject: [PATCH 18/31] fix: make the device section belongs to walletId instead of merely to its transport --- .../to/bitkit/repositories/HwWalletRepo.kt | 32 +++++++++- .../to/bitkit/viewmodels/TransferViewModel.kt | 7 +++ .../bitkit/repositories/HwWalletRepoTest.kt | 59 +++++++++++++++++++ .../viewmodels/TransferViewModelTest.kt | 16 +++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 4887d6aaf..69c2626ee 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -216,12 +216,34 @@ class HwWalletRepo @Inject constructor( } } + /** + * Makes the device session belong to [walletId], not merely to its transport. A device holds + * one identity open at a time, so a session opened for another wallet on the same device would + * otherwise be accepted and sign with the wrong seed. The standard wallet needs no secret to + * reopen; a passphrase wallet does, which the caller has to collect. + */ suspend fun ensureConnected(walletId: String): Result = withContext(ioDispatcher) { runSuspendCatching { - trezorRepo.ensureConnected(transportDeviceId(walletId)).getOrThrow() + val deviceId = transportDeviceId(walletId) + val features = trezorRepo.ensureConnected(deviceId).getOrThrow() + if (trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) { + return@runSuspendCatching features + } + + Logger.info("Reopening '$walletId': session belongs to another identity", context = TAG) + if (devicesForWallet(walletId).any { it.passphraseProtected }) throw HwPassphraseRequiredError() + + val reopened = trezorRepo.setWalletMode(TrezorWalletMode.STANDARD).getOrThrow() + if (!trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) { + throw HwPassphraseRequiredError() + } + reopened } } + /** A session opened before its identity could be resolved reports none and stays usable. */ + private fun String?.isIdentityOf(walletId: String): Boolean = this == null || this == walletId + /** * Whether reaching [walletId] needs the passphrase again. The device only holds one hidden * wallet open at a time and forgets the passphrase with the session, so a passphrase wallet @@ -327,6 +349,11 @@ class HwWalletRepo @Inject constructor( funding: HwFundingTransaction, ): Result = withContext(ioDispatcher) { runSuspendCatching { + // The session can change between connecting and signing, and signing the wrong seed + // would produce signatures that do not match the inputs being spent. + if (!trezorRepo.state.value.connectedWalletId().isIdentityOf(walletId)) { + throw HwPassphraseRequiredError() + } val signedTx = trezorRepo.signTxFromPsbt( psbtBase64 = funding.psbt, network = Env.network.toTrezorCoinType(), @@ -797,6 +824,9 @@ private val KnownDevice.displayName: String /** The entered passphrase resolves to a wallet Bitkit already watches. */ class HwPassphraseAlreadyAddedError : AppError("Passphrase wallet already added") +/** The device session belongs to another identity, and only its passphrase can reopen this one. */ +class HwPassphraseRequiredError : AppError("Passphrase needed to reopen this wallet") + /** The entered passphrase opened a different wallet than the one being signed from. */ class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet") diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 11f14bc6b..5c6eac654 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -61,6 +61,7 @@ import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwPassphraseMismatchError +import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -1020,6 +1021,12 @@ class TransferViewModel @Inject constructor( Logger.info("Hardware transfer cancelled on device for '$walletId'", context = TAG) return } + if (generateSequence(e) { it.cause }.any { it is HwPassphraseRequiredError }) { + // The device is open on another identity and only the passphrase reopens this one. + Logger.info("Asking for the passphrase to reopen hardware wallet '$walletId'", context = TAG) + _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } + return + } if (e.isTrezorDeviceBusy()) { Logger.warn("Blocked hardware transfer for locked or busy Trezor '$walletId'", e, context = TAG) ToastEventBus.send( diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 7a25a4820..ae52c110f 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -971,6 +971,65 @@ class HwWalletRepoTest : BaseUnitTest() { assertTrue(result.exceptionOrNull() is HwPassphraseAlreadyAddedError) } + @Test + fun `ensureConnected reopens the standard wallet when a hidden identity holds the session`() = test { + // A session on the same transport is not the same wallet: signing the standard wallet's + // inputs on a hidden-seed session would derive the wrong keys. + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + whenever { trezorRepo.ensureConnected("dev1") }.thenReturn(Result.success(mock())) + val reopenedFeatures = mock() + val reopened = ConnectedTrezorDevice(id = "dev1", features = reopenedFeatures, walletId = HARDWARE_WALLET_ID) + whenever { trezorRepo.setWalletMode(TrezorWalletMode.STANDARD, "") }.thenAnswer { + trezorState.value = TrezorState(connected = reopened) + reopenedFeatures + } + val sut = createRepo() + + val result = sut.ensureConnected(HARDWARE_WALLET_ID) + + assertTrue(result.isSuccess) + verify(trezorRepo).setWalletMode(TrezorWalletMode.STANDARD, "") + } + + @Test + fun `ensureConnected demands the passphrase when another identity holds the session`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HARDWARE_WALLET_ID), + ) + whenever { trezorRepo.ensureConnected("dev1") }.thenReturn(Result.success(mock())) + val sut = createRepo() + + val result = sut.ensureConnected(HIDDEN_WALLET_ID) + + assertTrue(result.exceptionOrNull() is HwPassphraseRequiredError) + verify(trezorRepo, never()).setWalletMode(any(), any()) + } + + @Test + fun `signFunding refuses a session that belongs to another identity`() = test { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + ) + val sut = createRepo() + + val result = sut.signFunding(HARDWARE_WALLET_ID, funding) + + assertTrue(result.exceptionOrNull() is HwPassphraseRequiredError) + verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) + } + @Test fun `needsPassphrase only while the hidden wallet is not the live session`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 18c209a9d..356a7439f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -68,6 +68,7 @@ import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.HwPassphraseMismatchError +import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -627,6 +628,21 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).signFunding(any(), any()) } + @Test + fun `asks for the passphrase when the device session belongs to another identity`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + .thenReturn(Result.failure(HwPassphraseRequiredError())) + + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) + verify(hwWalletRepo, never()).signFunding(any(), any()) + } + @Test fun `onHwPassphraseSubmit signs once the reopened wallet matches`() = test { val order = previewBtOrder() From 712914de21712416a42b532f71a27755c59452ef Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 11:43:35 -0300 Subject: [PATCH 19/31] fix: reconnectWithPassphrase requires a live session that, by construction, doesn't exist when it's called and the mismatch path tears down the session before a retry --- .../to/bitkit/repositories/HwWalletRepo.kt | 4 +- .../java/to/bitkit/repositories/TrezorRepo.kt | 51 +++++++++++++------ .../bitkit/repositories/HwWalletRepoTest.kt | 13 +++-- .../to/bitkit/repositories/TrezorRepoTest.kt | 31 +++++++++++ 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 69c2626ee..ca303363f 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -265,7 +265,9 @@ class HwWalletRepo @Inject constructor( runSuspendCatching { val deviceId = transportDeviceId(walletId) val watchedBefore = hwWalletStore.loadKnownDevices().mapNotNull { it.resolvedWalletId() }.toSet() - trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, passphrase).getOrThrow() + // Not setWalletMode: the session this reopens is usually already gone, either + // because the app restarted or because a wrong passphrase closed it. + trezorRepo.connectWithWalletMode(deviceId, TrezorWalletMode.PASSPHRASE_HOST, passphrase).getOrThrow() val opened = trezorRepo.state.value.connectedWalletId() if (opened == walletId) return@runSuspendCatching diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 0b7906624..280808ad1 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -243,24 +243,45 @@ class TrezorRepo @Inject constructor( mode: TrezorWalletMode, passphrase: String = "", ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val deviceId = _state.value.connectedDeviceId() ?: throw AppError("No connected Trezor") - TrezorDebugLog.log("WALLET_MODE", "Switching to $mode, resetting session for $deviceId") - // Reset the session via disconnect/reconnect. disconnect() resets the - // UI handler's wallet mode to standard, so set the desired mode AFTER - // the disconnect and right before reconnecting. - runCatching { disconnect() } - // Reconnect by id WITHOUT a scan: scan() clears the discovered-device - // cache and a scan right after a disconnect usually finds nothing, - // whereas the cached handle (and direct address resolution) still work. - delay(WALLET_MODE_RECONNECT_DELAY_MS) - // Record the selection on the handler: THP reads it via - // currentSelection() to bind the passphrase at session creation, - // while non-THP devices re-request it mid-operation and are answered - // from the same value. connect() then derives the wallet from it. + connectWithWalletMode(deviceId, mode, passphrase).getOrThrow() + } + } + + /** + * Opens [deviceId] with an explicit wallet selection, whether or not a session is live. A + * passphrase is bound when the session is created, so an existing one is torn down first; with + * none, the device is reconnected from its stored entry. Reopening a hidden wallet after the + * app was restarted, or retrying once a wrong passphrase closed the session, both start here. + */ + suspend fun connectWithWalletMode( + deviceId: String, + mode: TrezorWalletMode, + passphrase: String = "", + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val hadSession = _state.value.connectedDeviceId() != null + TrezorDebugLog.log("WALLET_MODE", "Opening $mode session for $deviceId, hadSession=$hadSession") + if (hadSession) { + runSuspendCatching { disconnect() } + delay(WALLET_MODE_RECONNECT_DELAY_MS) + } + // Record the selection on the handler: THP reads it via currentSelection() to bind the + // passphrase at session creation, while non-THP devices re-request it mid-operation and + // are answered from the same value. Set it last, since disconnect() resets it. trezorUiHandler.setWalletMode(mode, passphrase) - connect(deviceId).getOrThrow() + if (hadSession) { + // Reconnect by id WITHOUT a scan: scan() clears the discovered-device cache and a + // scan right after a disconnect usually finds nothing, whereas the cached handle + // (and direct address resolution) still work. + connect(deviceId).getOrThrow() + } else { + // Nothing cached to reconnect to, so take the known-device path with its scan and + // bluetooth retries. + connectKnownDevice(deviceId, forceSession = true).getOrThrow() + } } } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index ae52c110f..d5f069dee 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -943,7 +943,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `connectWithPassphrase opens the hidden wallet and returns its identity`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) - whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "secret") } .thenReturn(Result.success(mock())) trezorState.value = TrezorState( connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), @@ -959,7 +959,7 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `connectWithPassphrase reports a passphrase wallet that is already watched`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) - whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "secret") } .thenReturn(Result.success(mock())) trezorState.value = TrezorState( connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), @@ -1046,9 +1046,11 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `reconnectWithPassphrase accepts a session that reopens the same wallet`() = test { + fun `reconnectWithPassphrase opens the wallet without a live session`() = test { + // No session is live here, which is the normal state when the prompt appears: going + // through the switch helper instead would fail with "No connected Trezor". whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) - whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "secret") } .thenAnswer { trezorState.value = TrezorState( connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), @@ -1060,6 +1062,7 @@ class HwWalletRepoTest : BaseUnitTest() { val result = sut.reconnectWithPassphrase(HIDDEN_WALLET_ID, "secret") assertTrue(result.isSuccess) + verify(trezorRepo, never()).setWalletMode(any(), any()) verify(trezorRepo, never()).disconnectStaleSession(any()) } @@ -1078,7 +1081,7 @@ class HwWalletRepoTest : BaseUnitTest() { Result.success(Unit) } // A wrong passphrase derives another wallet, which reading its accounts already stored. - whenever { trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "wrong") } + whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "wrong") } .thenAnswer { stored = stored + strayWallet trezorState.value = TrezorState( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index aaaeff4ac..1d679d1b0 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -45,6 +45,7 @@ import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler +import to.bitkit.services.TrezorWalletMode import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -1857,6 +1858,36 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).clearCredentials(any()) } + @Test + fun `connectWithWalletMode opens a passphrase session when none is live`() = test { + // Reopening a hidden wallet happens exactly when its session is gone, so requiring a live + // one would make the passphrase prompt unable to ever succeed. + val features = mockFeatures() + val knownDevice = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "hidden-native-xpub")) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + sut.initialize() + assertNull(sut.state.value.connectedDeviceId()) + + val result = sut.connectWithWalletMode(DEVICE_ID, TrezorWalletMode.PASSPHRASE_HOST, "secret") + + assertTrue(result.isSuccess, "err=${result.exceptionOrNull()}") + verify(trezorUiHandler).setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) + } + + @Test + fun `setWalletMode still requires a live session to switch`() = test { + sut = createSut() + + val result = sut.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") + + assertTrue(result.isFailure) + verify(trezorUiHandler, never()).setWalletMode(any(), any()) + } + @Test fun `forgetDevice keeps the stored label of the identity left behind`() = test { // Labels are written straight to the store, so the cached device list can be out of date; From e21c660292330c351ec801cf6da60b3bcd67183e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 13:20:22 -0300 Subject: [PATCH 20/31] fix: report failure if no target is found --- .../java/to/bitkit/repositories/HwWalletRepo.kt | 3 +++ .../to/bitkit/repositories/HwWalletRepoTest.kt | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index ca303363f..e322b8b82 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -426,6 +426,9 @@ class HwWalletRepo @Inject constructor( watcherMutex.withLock { val knownDevices = hwWalletStore.loadKnownDevices() val targets = knownDevices.filter { it.resolvedWalletId() == walletId } + // Without an entry there is nothing to forget, and the check below would pass on an + // empty set: report the failure instead of telling the user the wallet was removed. + require(targets.isNotEmpty()) { "Unknown hardware wallet '$walletId'" } activeWatchers.toList() .filter { it.toWalletId() == walletId } .forEach { diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index d5f069dee..f882e3940 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -920,6 +920,20 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(listOf(false, true), sut.wallets.value.map { it.isConnected }) } + @Test + fun `removeDevice reports failure when no entry tracks the wallet`() = test { + // Nothing to forget must not read as a successful removal: the post-condition below holds + // trivially on an empty set, so the caller would show the wallet as gone while it stays. + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val sut = createRepo() + + val result = sut.removeDevice("unknown-wallet") + + assertTrue(result.isFailure) + verify(trezorRepo, never()).forgetDevice(any(), anyOrNull()) + verify(activityRepo, never()).deleteForWallet("unknown-wallet") + } + @Test fun `removeDevice forgets only the requested identity of the device`() = test { storeData.value = HwWalletData(knownDevices = listOf(device, hiddenWallet)) From bfd61d70336648a8d9ff86f0cbaf1e2df1a9b4a9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 13:26:30 -0300 Subject: [PATCH 21/31] fix: cancel hwTransferSignJob when dismiss passphrase --- .../to/bitkit/viewmodels/TransferViewModel.kt | 8 ++++++- .../viewmodels/TransferViewModelTest.kt | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5c6eac654..4a9c79bfa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -875,6 +875,9 @@ class TransferViewModel @Inject constructor( hwTransferSignJob = null result .onSuccess { + // The prompt can be swiped away while the device is still reopening the wallet, + // and the confirm below starts a new job that a late cancel would not reach. + if (!_spendingUiState.value.isHwPassphraseRequired) return@launch _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } onTransferToSpendingHwConfirm(order, walletId) } @@ -882,8 +885,11 @@ class TransferViewModel @Inject constructor( } } + /** Backing out of the prompt also drops the reopen it started, so no signature is requested. */ fun onHwPassphraseDismiss() { - _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } + hwTransferSignJob?.cancel() + hwTransferSignJob = null + _spendingUiState.update { it.copy(isHwPassphraseRequired = false, isVerifyingHwPassphrase = false) } } private suspend fun handleHardwarePassphraseFailure(e: Throwable, walletId: String) { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 356a7439f..025b223cc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -685,6 +685,28 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).signFunding(eq(HARDWARE_WALLET_ID), eq(funding)) } + @Test + fun `dismissing the passphrase prompt stops the reopen from starting a signature`() = test { + // The sheet can be swiped away while the device is still reopening the wallet; the transfer + // the user backed out of must not go on to ask the device for a signature. + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) + whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } + .thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + .thenReturn(Result.success(mock())) + + sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "secret") + sut.onHwPassphraseDismiss() + advanceUntilIdle() + + assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) + assertFalse(sut.spendingUiState.value.isVerifyingHwPassphrase) + verify(hwWalletRepo, never()).ensureConnected(any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + } + @Test fun `onHwPassphraseSubmit does not sign when the passphrase opens another wallet`() = test { val order = previewBtOrder() From 891d9bf7b7a0f0120139e6bc17ce68063ef58c37 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 13:34:58 -0300 Subject: [PATCH 22/31] fix: check connectedWalletId on forgetDevice instead of only deviceId --- .../java/to/bitkit/repositories/TrezorRepo.kt | 30 ++++++--- .../to/bitkit/repositories/TrezorRepoTest.kt | 66 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 280808ad1..c57820b52 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -892,7 +892,25 @@ class TrezorRepo @Inject constructor( suspend fun forgetDevice(deviceId: String, walletKey: String? = null): Result = withContext(ioDispatcher) { runSuspendCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") - val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { + // The store is the source of truth here: labels are written straight to it, so a + // cached entry taking precedence would rewrite the wallets left behind without theirs. + val stored = loadKnownDevices() + val storedEntries = stored.map { it.id to it.walletKey }.toSet() + val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } + val isForgotten: (KnownDevice) -> Boolean = { + it.id == deviceId && (walletKey == null || it.walletKey == walletKey) + } + val forgotten = knownDevices.filter(isForgotten) + val updated = knownDevices.filterNot(isForgotten) + val keepsDevice = updated.any { it.id == deviceId } + + // Only the session of what is being forgotten may be torn down: a device can hold + // another identity open, and that wallet is still paired and still signing. + val connectedWalletId = _state.value.connectedWalletId() + val sessionIsForgotten = !keepsDevice || + connectedWalletId == null || + forgotten.any { it.walletId == connectedWalletId } + val disconnectResult = if (_state.value.connectedDeviceId() == deviceId && sessionIsForgotten) { try { runSuspendCatching { trezorService.disconnect() @@ -908,15 +926,7 @@ class TrezorRepo @Inject constructor( } else { Result.success(Unit) } - // The store is the source of truth here: labels are written straight to it, so a - // cached entry taking precedence would rewrite the wallets left behind without theirs. - val stored = loadKnownDevices() - val storedEntries = stored.map { it.id to it.walletKey }.toSet() - val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } - val updated = knownDevices.filterNot { - it.id == deviceId && (walletKey == null || it.walletKey == walletKey) - } - val clearCredentialsResult = if (updated.none { it.id == deviceId }) { + val clearCredentialsResult = if (!keepsDevice) { TrezorDebugLog.log("FORGET", "Clearing credentials...") trezorTransport.clearDeviceCredential(deviceId) runSuspendCatching { trezorService.clearCredentials(deviceId) } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 1d679d1b0..96d47f852 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -1888,6 +1888,70 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorUiHandler, never()).setWalletMode(any(), any()) } + @Test + fun `forgetDevice keeps the live session of an identity it is not forgetting`() = test { + // The device holds one identity open at a time; forgetting a different wallet must not + // close the session the user is still transacting with. + val keptKey = "kept-native-xpub" + val kept = mockKnownDevice( + xpubs = ALL_ADDRESS_TYPE_KEYS.associateWith { keptKey }, + walletId = "kept-wallet", + ) + val forgottenXpubs = mapOf("nativeSegwit" to "forgotten-native-xpub") + val forgotten = mockKnownDevice( + xpubs = forgottenXpubs, + walletId = "forgotten-wallet", + passphraseProtected = true, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(forgotten, kept)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever( + trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) + ).thenAnswer { mockPublicKeyResponse(xpub = keptKey, path = it.getArgument(0)) } + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + assertEquals("kept-wallet", sut.state.value.connectedWalletId()) + + val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(forgottenXpubs)) + + assertTrue(result.isSuccess) + assertEquals("kept-wallet", sut.state.value.connectedWalletId()) + verify(trezorService, never()).disconnect() + verify(trezorTransport, never()).clearDeviceCredential(any()) + } + + @Test + fun `forgetDevice closes the session when it belongs to the identity being forgotten`() = test { + val forgottenKey = "forgotten-native-xpub" + val forgottenXpubs = ALL_ADDRESS_TYPE_KEYS.associateWith { forgottenKey } + val forgotten = mockKnownDevice( + xpubs = forgottenXpubs, + walletId = "forgotten-wallet", + passphraseProtected = true, + ) + val kept = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "kept-native-xpub"), walletId = "kept-wallet") + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(forgotten, kept)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever( + trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) + ).thenAnswer { mockPublicKeyResponse(xpub = forgottenKey, path = it.getArgument(0)) } + sut = createSut() + sut.scan() + sut.connect(DEVICE_ID) + assertEquals("forgotten-wallet", sut.state.value.connectedWalletId()) + + val result = sut.forgetDevice(DEVICE_ID, walletKey = walletKeyOf(forgottenXpubs)) + + assertTrue(result.isSuccess) + assertNull(sut.state.value.connectedDevice()) + verify(trezorService).disconnect() + } + @Test fun `forgetDevice keeps the stored label of the identity left behind`() = test { // Labels are written straight to the store, so the cached device list can be out of date; @@ -1926,6 +1990,8 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService).clearCredentials(DEVICE_ID) } + private val ALL_ADDRESS_TYPE_KEYS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") + private fun walletKeyOf(xpubs: Map) = xpubs.values.sorted().joinToString() // endregion From 6aac9f39a040798ed2b00a2ea568af79ce8d3f28 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 13:49:54 -0300 Subject: [PATCH 23/31] fix: connect now supersedes entries of a seed the device no longer carries --- .../main/java/to/bitkit/models/KnownDevice.kt | 5 ++ .../java/to/bitkit/repositories/TrezorRepo.kt | 23 +++++--- .../to/bitkit/repositories/TrezorRepoTest.kt | 53 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt index c6f7826dc..9e20dce97 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -25,4 +25,9 @@ data class KnownDevice( * passphrase itself is never persisted. */ val passphraseProtected: Boolean = false, + /** + * The Trezor's own device id, which it regenerates when wiped. Entries of the same transport + * that report a different one belong to a seed the device can no longer sign for. + */ + val trezorDeviceId: String? = null, ) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index c57820b52..42996e7c6 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1146,14 +1146,9 @@ class TrezorRepo @Inject constructor( ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id), // The selection bound to the session is what derived these xpubs. passphraseProtected = previous?.passphraseProtected == true || isPassphraseSession, + trezorDeviceId = features.deviceId ?: previous?.trezorDeviceId, ) - // Replace the entry this connect refreshed, plus any entry already holding the resulting - // identity: reading a previously rejected address type changes the walletKey, and matching - // on the new key alone would leave the stale entry behind as a duplicate wallet. - val updated = knownDevices.filterNot { - (it.id == known.id && it.walletKey == known.walletKey) || - (previous != null && it.id == previous.id && it.walletKey == previous.walletKey) - } + known + val updated = knownDevices.filterNot { it.isReplacedBy(known, refreshed = previous) } + known saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } return known @@ -1444,6 +1439,20 @@ data class ConnectedTrezorDevice( private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId +/** + * Whether a stored entry gives way to the one just read. That covers the identity it holds and the + * entry this connect refreshed, since reading a previously rejected address type changes the + * walletKey and matching on the new key alone would leave the old entry behind as a duplicate. + * Wallets of a seed the device no longer carries go too: nothing would ever supersede them by key + * material. An unknown device id proves nothing, so those entries are left alone. + */ +private fun KnownDevice.isReplacedBy(known: KnownDevice, refreshed: KnownDevice?): Boolean { + if (id != known.id) return false + if (walletKey == known.walletKey) return true + if (refreshed != null && walletKey == refreshed.walletKey) return true + return known.trezorDeviceId != null && trezorDeviceId != null && trezorDeviceId != known.trezorDeviceId +} + private val KnownDevice.walletKey: String get() = walletKey(xpubs, id) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 96d47f852..a73fce423 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -147,9 +147,11 @@ class TrezorRepoTest : BaseUnitTest() { model: String? = DEVICE_MODEL, pinProtection: Boolean? = null, unlocked: Boolean? = null, + deviceId: String? = null, ): TrezorFeatures = mock { on { this.label }.thenReturn(label) on { this.model }.thenReturn(model) + on { this.deviceId }.thenReturn(deviceId) on { this.pinProtection }.thenReturn(pinProtection) on { this.unlocked }.thenReturn(unlocked) } @@ -194,6 +196,7 @@ class TrezorRepoTest : BaseUnitTest() { customLabel: String? = null, walletId: String = "wallet-id", passphraseProtected: Boolean = false, + trezorDeviceId: String? = null, ) = KnownDevice( id = id, name = name, @@ -206,6 +209,7 @@ class TrezorRepoTest : BaseUnitTest() { customLabel = customLabel, walletId = walletId, passphraseProtected = passphraseProtected, + trezorDeviceId = trezorDeviceId, ) // region initialize @@ -792,6 +796,55 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(hidden.xpubs.values.none { it in standard.xpubs.values }) } + @Test + fun `connect supersedes entries of a seed the device no longer carries`() = test { + // A wiped and restored device reports a new device id and different keys, so nothing would + // ever match those entries again; they would linger as wallets that can never sign. + val stale = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "old-seed-xpub"), + trezorDeviceId = "old-device-id", + ) + val features = mockFeatures(deviceId = "new-device-id") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(stale)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val saved = captor.firstValue.single() + assertEquals("new-device-id", saved.trezorDeviceId) + assertTrue(saved.xpubs.values.none { it == "old-seed-xpub" }) + } + + @Test + fun `connect keeps another identity of the same device id`() = test { + // Same device, same seed, different passphrase: both entries must survive. + val standard = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "standard-native-xpub"), + trezorDeviceId = "same-device-id", + ) + val features = mockFeatures(deviceId = "same-device-id") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(standard)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Hidden("secret")) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(2, captor.firstValue.size) + assertEquals(standard, captor.firstValue.first()) + } + @Test fun `connect keeps the standard wallet unprotected when its keys are re-read`() = test { val standard = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'")) From 35824b4d1b396d6694a8ae8f07cd6cb21928f138 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 14:00:05 -0300 Subject: [PATCH 24/31] fix: add guard to passphraseProtected to protect default wallets --- .../java/to/bitkit/repositories/TrezorRepo.kt | 13 +++-- .../to/bitkit/repositories/TrezorRepoTest.kt | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 42996e7c6..806037193 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1109,7 +1109,7 @@ class TrezorRepo @Inject constructor( val storedEntries = stored.map { it.id to it.walletKey }.toSet() val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } val fetchResult = fetchAccountXpubs() - val isPassphraseSession = trezorUiHandler.currentSelection() != WalletSelection.Standard + val selection = trezorUiHandler.currentSelection() // A passphrase wallet is a separate identity on the same physical device, so the transport // id alone no longer identifies an entry: matching by it would overwrite another identity // or blend two identities' xpubs into one record. Shared key material is the identity, so @@ -1144,8 +1144,15 @@ class TrezorRepo @Inject constructor( customLabel = previous?.customLabel, walletId = previous?.walletId?.takeIf { it.isNotBlank() } ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id), - // The selection bound to the session is what derived these xpubs. - passphraseProtected = previous?.passphraseProtected == true || isPassphraseSession, + // The selection that derived these keys is authoritative, so a wallet wrongly marked + // hidden is corrected the next time it is opened rather than staying gated behind a + // passphrase forever. On-device entry cannot say which wallet was opened, so it keeps + // what the entry already knew and assumes hidden only for one it has never seen. + passphraseProtected = when (selection) { + WalletSelection.Standard -> false + is WalletSelection.Hidden -> true + WalletSelection.OnDevice -> previous?.passphraseProtected ?: true + }, trezorDeviceId = features.deviceId ?: previous?.trezorDeviceId, ) val updated = knownDevices.filterNot { it.isReplacedBy(known, refreshed = previous) } + known diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index a73fce423..d6c72db4c 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -845,6 +845,53 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(standard, captor.firstValue.first()) } + @Test + fun `connect clears a passphrase flag the standard wallet should never have had`() = test { + // Marked hidden it would demand a passphrase that opens a different wallet, so the standard + // wallet could never be signed with again; opening it must be able to correct that. + val misflagged = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'"), + passphraseProtected = true, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(misflagged)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertFalse(captor.firstValue.single().passphraseProtected) + } + + @Test + fun `connect keeps the passphrase flag when the device asked on its own screen`() = test { + // On-device entry does not say which wallet was opened, so it must not downgrade a wallet + // already known to be hidden. + val hidden = mockKnownDevice( + xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'"), + passphraseProtected = true, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(hidden)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.OnDevice) + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertTrue(captor.firstValue.single().passphraseProtected) + } + @Test fun `connect keeps the standard wallet unprotected when its keys are re-read`() = test { val standard = mockKnownDevice(xpubs = mapOf("nativeSegwit" to "xpub-m/84'/1'/0'")) From 97165e7a5b07cd4a421c3de32d27126d9512f589 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 14:11:30 -0300 Subject: [PATCH 25/31] fix: warns if walletId was not found in onFinishClick --- .../ui/sheets/hardware/HwConnectViewModel.kt | 16 +++++-- .../sheets/hardware/HwConnectViewModelTest.kt | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 6eb498d47..5339d1f65 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -253,14 +253,22 @@ class HwConnectViewModel @Inject constructor( } fun onFinishClick() { - val walletId = _uiState.value.pairedWalletId - if (walletId == null) { + val state = _uiState.value + if (state.pairedDeviceId == null) { setEffect(HwConnectEffect.Dismiss) return } - val label = _uiState.value.labelInput + // The wallet list can still be catching up with the identity that was just paired, so fall + // back to the one the session opened rather than dropping the name the user typed. + val walletId = state.pairedWalletId ?: hwWalletRepo.deviceState.value.connectedWalletId() + val label = state.labelInput viewModelScope.launch { - persistLabel(walletId, label) + if (walletId != null) { + persistLabel(walletId, label) + } else { + Logger.warn("Finished pairing '${state.pairedDeviceId}' before its identity resolved", context = TAG) + } + // The device is paired either way, so finish the flow instead of dropping out of it. setEffect(HwConnectEffect.Finish) } } diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index 20da449aa..aafa25fd9 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -387,6 +388,50 @@ class HwConnectViewModelTest : BaseUnitTest() { assertEquals("No Pass", sut.uiState.value.labelInput) } + @Test + fun `finishing labels the session identity while the wallet list catches up`() = test { + // The paired wallet has not reached the list yet, so the typed name would otherwise be + // dropped and the flow closed instead of finished. + val connectedFeatures = features(model = "Safe 3") + deviceState.value = TrezorState( + nearbyDevices = persistentListOf(deviceInfo("dev1", model = "Safe 3")), + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = "wallet-1"), + ) + whenever(hwWalletRepo.scan(includeBluetooth = true)).thenReturn(Result.success(emptyList())) + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + whenever(hwWalletRepo.setDeviceLabel("wallet-1", "My Trezor")).thenReturn(Result.success(Unit)) + sut.onIntroContinue() + runCurrent() + sut.onConnectClick() + runCurrent() + sut.onLabelChange("My Trezor") + + sut.effects.test { + sut.onFinishClick() + assertEquals(HwConnectEffect.Finish, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo).setDeviceLabel("wallet-1", "My Trezor") + } + + @Test + fun `finishing completes the flow even when no identity resolved`() = test { + givenDeviceFound() + val connectedFeatures = features(model = "Safe 3") + whenever(hwWalletRepo.connect("dev1")).thenReturn(Result.success(connectedFeatures)) + sut.onConnectClick() + runCurrent() + + sut.effects.test { + sut.onFinishClick() + assertEquals(HwConnectEffect.Finish, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(hwWalletRepo, never()).setDeviceLabel(any(), any()) + } + @Test fun `persists the label of the wallet left behind when adding a passphrase wallet`() = test { // Each identity is named on its own paired step, so the standard wallet's name must be From e14d1148ef4b69830a3a3fe93506d70765b97dc1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 14:21:56 -0300 Subject: [PATCH 26/31] fix: observeConnectedWallet falls back to wallets.firstOrNull { deviceId in it.deviceIds } and unconditionally writes pairedWalletId = wallet.id, so a wallets emission that does not yet contain the just-added hidden wallet resets the paired identity back to the standard wallet. --- .../ui/sheets/hardware/HwConnectViewModel.kt | 19 +++++++++++------ .../sheets/hardware/HwConnectViewModelTest.kt | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index 5339d1f65..b44096f8b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -356,12 +356,19 @@ class HwConnectViewModel @Inject constructor( hwWalletRepo.wallets.collect { wallets -> val state = _uiState.value val deviceId = state.pairedDeviceId ?: return@collect - // A device can hold several passphrase wallets, so prefer the identity being - // paired, then the one holding the session; sharing a transport id proves nothing. - val wallet = state.pairedWalletId?.let { id -> wallets.firstOrNull { it.id == id } } - ?: wallets.firstOrNull { deviceId in it.deviceIds && it.isConnected } - ?: wallets.firstOrNull { deviceId in it.deviceIds } - ?: return@collect + // A device can hold several passphrase wallets, so sharing a transport id proves + // nothing about which one is being paired. + val pairedWalletId = state.pairedWalletId + val wallet = if (pairedWalletId != null) { + // The store publishes a newly watched identity asynchronously: wait for it + // rather than falling back to another wallet and reporting its name, balance + // and label as this one's. + wallets.firstOrNull { it.id == pairedWalletId } ?: return@collect + } else { + wallets.firstOrNull { deviceId in it.deviceIds && it.isConnected } + ?: wallets.firstOrNull { deviceId in it.deviceIds } + ?: return@collect + } _uiState.update { it.copy( pairedWalletId = wallet.id, diff --git a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt index aafa25fd9..0198d2f45 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/hardware/HwConnectViewModelTest.kt @@ -447,6 +447,27 @@ class HwConnectViewModelTest : BaseUnitTest() { verify(hwWalletRepo).setDeviceLabel("wallet-dev1", "My Savings") } + @Test + fun `a wallet emission without the new identity does not switch back to the standard wallet`() = test { + // The store publishes the new identity asynchronously while other sources re-emit sooner, + // so an emission listing only the standard wallet must not take over the paired step. + givenPairedDevice() + whenever(hwWalletRepo.connectWithPassphrase("dev1", "secret")) + .thenReturn(Result.success("hidden-wallet")) + sut.onPassphraseClick() + sut.onPassphraseChange("secret") + sut.onPassphraseSubmit() + runCurrent() + + wallets.value = persistentListOf( + hwWallet("dev1", name = "Standard Trezor", balance = 27uL, walletId = "standard-wallet"), + ) + + assertEquals("hidden-wallet", sut.uiState.value.pairedWalletId) + assertFalse(sut.uiState.value.deviceName == "Standard Trezor") + assertEquals(0uL, sut.uiState.value.balanceSats) + } + @Test fun `keeps the typed label when the new wallet is published afterwards`() = test { // The store publishes a newly watched identity asynchronously, so its emission can land From a9a6a9d6ca5dbdcb3577364dd69d36e1c062e82a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 14:27:27 -0300 Subject: [PATCH 27/31] fix: Released-session cleanup sits inside runCatching after the release POST, so a failed release still leaves the stale session id cached. --- .../bitkit/services/TrezorBridgeTransport.kt | 45 ++++++++++++++----- .../services/TrezorBridgeTransportTest.kt | 29 ++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 0a1cdbaff..72800c0f9 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -44,6 +44,12 @@ class TrezorBridgeTransport( private const val READ_TIMEOUT_MS = 30_000 private const val CALL_READ_TIMEOUT_MS = 120_000 + /** What the bridge calls the absence of a held session in an acquire path. */ + private const val NO_SESSION = "null" + + /** The bridge's answer when the session offered as the previous one is not the one it holds. */ + private const val WRONG_PREVIOUS_SESSION = "wrong previous session" + /** * Trezor protobuf MessageType_SignTx. This is the only call that waits * for on-device signing. @@ -90,18 +96,35 @@ class TrezorBridgeTransport( fun openDevice(path: String): TrezorTransportWriteResult { val rawPath = rawBridgePath(path) - val previousSession = openSessions.remove(path) ?: enumeratedSessions[path] ?: "null" + val previousSession = openSessions.remove(path) ?: enumeratedSessions[path] ?: NO_SESSION - return runCatching { - val response = post("/acquire/${encode(rawPath)}/${encode(previousSession)}") - val session = json.decodeFromString(response).session - openSessions[path] = session - Logger.info("Opened Trezor Bridge device '$path'", context = TAG) - 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", errorCode = null) - } + return acquire(path, rawPath, previousSession) + .recoverCatching { error -> + // The remembered session goes stale in both directions: a release the bridge applied + // but never confirmed, and one that never reached it at all. Rather than trust the + // cache, ask which session it holds and try once more. + if (error.message?.contains(WRONG_PREVIOUS_SESSION, ignoreCase = true) != true) throw error + Logger.info("Refreshing the session held for '$path' after a stale acquire", context = TAG) + runCatching { enumerateDevices() } + acquire(path, rawPath, enumeratedSessions[path] ?: NO_SESSION).getOrThrow() + } + .fold( + onSuccess = { TrezorTransportWriteResult(success = true, error = "", errorCode = null) }, + onFailure = { + Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG) + TrezorTransportWriteResult( + success = false, + error = it.message ?: "Bridge open failed", + errorCode = null, + ) + }, + ) + } + + private fun acquire(path: String, rawPath: String, previousSession: String): Result = runCatching { + val response = post("/acquire/${encode(rawPath)}/${encode(previousSession)}") + openSessions[path] = json.decodeFromString(response).session + Logger.info("Opened Trezor Bridge device '$path' after session '$previousSession'", context = TAG) } fun closeDevice(path: String): TrezorTransportWriteResult { diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 9dbdbbdfb..4caa88e6e 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -90,6 +90,35 @@ class TrezorBridgeTransportTest { assertTrue(releaseCalled) } + @Test + fun `reacquires with the session the bridge reports when the remembered one is stale`() { + // A release the bridge applied but never confirmed, or one that never reached it, both + // leave the remembered session wrong; the bridge is the authority on which one it holds. + var reportedSession = "stale-session" + server.route = { request -> + when { + request.path == "/enumerate" -> + TestHttpResponse("""[{"path":"emulator:21324","session":"$reportedSession"}]""") + + request.path == "/acquire/emulator%3A21324/stale-session" -> + TestHttpResponse("""{"error":"wrong previous session"}""", statusCode = 400) + + request.path == "/acquire/emulator%3A21324/live-session" -> + TestHttpResponse("""{"session":"live-session"}""") + + else -> TestHttpResponse("""{"error":"unexpected"}""", statusCode = 404) + } + } + val sut = createSut() + val device = sut.enumerateDevices().single() + reportedSession = "live-session" + + val result = sut.openDevice(device.path) + + assertTrue(result.success, "requests=${server.requests}") + assertTrue(server.requests.contains("POST /acquire/emulator%3A21324/live-session")) + } + @Test fun `reopening after a release acquires without the stale session`() { // Switching to a passphrase wallet closes and reopens the session; offering the released From b64c6d4118a93b3b4084b07483aa333bac37e3c6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 7 Aug 2026 14:36:22 -0300 Subject: [PATCH 28/31] chore: lint --- .../ui/screens/transfer/hardware/SpendingHwSignScreen.kt | 2 +- app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 c83313b97..65901f5a1 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.IBtOrder import to.bitkit.R +import to.bitkit.models.safe import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.FeeInfo @@ -36,7 +37,6 @@ import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.models.safe import to.bitkit.viewmodels.TransferViewModel @Composable diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index d6c72db4c..3ff2a0925 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -71,6 +71,9 @@ class TrezorRepoTest : BaseUnitTest() { private const val TEST_SIGNATURE = "signature123" private const val TEST_ADDRESS = "bc1qtest" private const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." + + /** The address types the store keys account xpubs by. */ + private val ALL_ADDRESS_TYPE_KEYS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") } @get:Rule(order = 1) @@ -2090,8 +2093,6 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService).clearCredentials(DEVICE_ID) } - private val ALL_ADDRESS_TYPE_KEYS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") - private fun walletKeyOf(xpubs: Map) = xpubs.values.sorted().joinToString() // endregion From fdc48d7e7343675c11467520af63156c59f35985 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Aug 2026 07:16:53 -0300 Subject: [PATCH 29/31] fix: dont override label when transport id changes --- .../java/to/bitkit/repositories/TrezorRepo.kt | 7 +++- .../to/bitkit/repositories/TrezorRepoTest.kt | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 806037193..ee3ef04ee 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1132,6 +1132,11 @@ class TrezorRepo @Inject constructor( if (xpubs.isEmpty()) { throw AppError("Could not read any account keys from your Trezor. Reconnect and try again.") } + // Labels are set for the wallet, not for the transport it happens to be reached over, so a + // wallet showing up on a new path (a fresh usb/bluetooth handle, or a restarted bridge) + // must keep the name the user gave it instead of falling back to the device's own. + val identityKey = walletKey(xpubs, deviceInfo.id) + val named = previous ?: knownDevices.firstOrNull { it.walletKey == identityKey } val known = KnownDevice( id = deviceInfo.id, name = deviceInfo.name, @@ -1141,7 +1146,7 @@ class TrezorRepo @Inject constructor( model = features.model ?: deviceInfo.model, lastConnectedAt = clock.nowMs(), xpubs = xpubs, - customLabel = previous?.customLabel, + customLabel = named?.customLabel, walletId = previous?.walletId?.takeIf { it.isNotBlank() } ?: knownDevices.findHardwareWalletId(xpubs, fallback = deviceInfo.id), // The selection that derived these keys is authoritative, so a wallet wrongly marked diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 3ff2a0925..b7ba3cddb 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -799,6 +799,39 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(hidden.xpubs.values.none { it in standard.xpubs.values }) } + @Test + fun `connect keeps the custom label when the wallet appears on a new transport`() = test { + // A restarted bridge or a fresh usb handle gives the same wallet a new transport id; the + // name the user set is the wallet's, and the tile prefers the connected entry, so losing it + // here renames the wallet to the device's own name and finishing writes that over the rest. + val sharedKey = "shared-native-xpub" + val onOldTransport = mockKnownDevice( + id = "old-transport", + path = "bridge:1", + xpubs = ALL_ADDRESS_TYPE_KEYS.associateWith { sharedKey }, + customLabel = "No Pass", + walletId = "standard-wallet", + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(onOldTransport)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(mockDeviceInfo())) + whenever( + trezorService.getPublicKey(path = any(), coin = anyOrNull(), showOnTrezor = eq(false)) + ).thenAnswer { mockPublicKeyResponse(xpub = sharedKey, path = it.getArgument(0)) } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + val added = captor.firstValue.single { it.id == DEVICE_ID } + assertEquals("No Pass", added.customLabel) + assertEquals("standard-wallet", added.walletId) + } + @Test fun `connect supersedes entries of a seed the device no longer carries`() = test { // A wiped and restored device reports a new device id and different keys, so nothing would From 09327408ec8c130560e60be58522c90d6667935e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Aug 2026 07:30:39 -0300 Subject: [PATCH 30/31] fix: ensure forgetDevice remove an identity from all transport layers --- .../java/to/bitkit/repositories/TrezorRepo.kt | 8 ++++++-- .../to/bitkit/repositories/TrezorRepoTest.kt | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index ee3ef04ee..2c66ba607 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -897,8 +897,12 @@ class TrezorRepo @Inject constructor( val stored = loadKnownDevices() val storedEntries = stored.map { it.id to it.walletKey }.toSet() val knownDevices = stored + _state.value.knownDevices.filter { (it.id to it.walletKey) !in storedEntries } - val isForgotten: (KnownDevice) -> Boolean = { - it.id == deviceId && (walletKey == null || it.walletKey == walletKey) + // Scoped to the identity, not to the transport it was reached over: removing it in one + // write keeps repeated calls, a lagging read and a concurrent connect from leaving a + // sibling entry of the same wallet behind. + val isForgotten: (KnownDevice) -> Boolean = when (walletKey) { + null -> { entry -> entry.id == deviceId } + else -> { entry -> entry.walletKey == walletKey } } val forgotten = knownDevices.filter(isForgotten) val updated = knownDevices.filterNot(isForgotten) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index b7ba3cddb..3a2419e8b 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -1976,6 +1976,26 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).saveKnownDevices(listOf(otherDevice)) } + @Test + fun `forgetDevice removes an identity from every transport it was paired over`() = test { + // Removal walks the entries of one wallet, so each call has to take the whole identity out: + // a lagging store read or a connect landing mid-removal would otherwise write a sibling + // entry back and leave the wallet watched. + val sharedXpubs = mapOf("nativeSegwit" to "shared-native-xpub") + val overBluetooth = mockKnownDevice(id = "ble1", path = "ble:AA:BB", xpubs = sharedXpubs) + val overUsb = mockKnownDevice(id = "usb1", path = "/dev/trezor1", xpubs = sharedXpubs) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(overBluetooth, overUsb)) + sut = createSut() + + val result = sut.forgetDevice("usb1", walletKey = walletKeyOf(sharedXpubs)) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(emptyList(), captor.lastValue) + assertTrue(sut.state.value.knownDevices.isEmpty()) + } + @Test fun `forgetDevice keeps the device paired while another identity remains`() = test { val standardXpubs = mapOf("nativeSegwit" to "standard-native-xpub") From 91ed521aa9d990a10eaa52d2baca904cdbdf39d3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Aug 2026 07:50:47 -0300 Subject: [PATCH 31/31] fix: display specific error when passphrase protection is disabled on Trezor suite --- .../to/bitkit/repositories/HwWalletRepo.kt | 9 +++++++ .../ui/sheets/hardware/HwConnectViewModel.kt | 2 ++ app/src/main/res/values/strings.xml | 1 + .../bitkit/repositories/HwWalletRepoTest.kt | 26 +++++++++++++++++-- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index e322b8b82..65e33a80a 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -196,6 +196,12 @@ class HwWalletRepo @Inject constructor( suspend fun connectWithPassphrase(deviceId: String, passphrase: String): Result = withContext(ioDispatcher) { runSuspendCatching { + // A device with passphrase protection turned off ignores the passphrase and simply + // reopens the standard wallet, which would surface as "already added" and leave the + // user retyping a passphrase that can never take effect. + if (trezorRepo.state.value.connectedDevice()?.passphraseProtection != true) { + throw HwPassphraseDisabledError() + } val watchedWalletIds = hwWalletStore.loadKnownDevices().mapNotNull { it.resolvedWalletId() }.toSet() trezorRepo.setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, passphrase).getOrThrow() val walletId = requireNotNull(trezorRepo.state.value.connectedWalletId()) { @@ -826,6 +832,9 @@ fun resolveHwWalletName(label: String?, model: String?, customLabel: String? = n private val KnownDevice.displayName: String get() = resolveHwWalletName(label = label, model = model, customLabel = customLabel) +/** The device has passphrase protection turned off, so it cannot open a hidden wallet at all. */ +class HwPassphraseDisabledError : AppError("Passphrase protection is off on this device") + /** The entered passphrase resolves to a wallet Bitkit already watches. */ class HwPassphraseAlreadyAddedError : AppError("Passphrase wallet already added") diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt index b44096f8b..26a312306 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwConnectViewModel.kt @@ -20,6 +20,7 @@ import to.bitkit.R import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.models.Toast import to.bitkit.repositories.HwPassphraseAlreadyAddedError +import to.bitkit.repositories.HwPassphraseDisabledError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH import to.bitkit.repositories.resolveHwWalletName @@ -241,6 +242,7 @@ class HwConnectViewModel @Inject constructor( private suspend fun onPassphraseFailed(error: Throwable) { _uiState.update { it.copy(isSubmittingPassphrase = false, passphraseInput = "") } val description = when (error) { + is HwPassphraseDisabledError -> context.getString(R.string.hardware__passphrase_disabled) is HwPassphraseAlreadyAddedError -> context.getString(R.string.hardware__passphrase_duplicate) else if error.isTrezorDeviceBusy() -> TrezorErrorPresenter.userMessage(context, error) else -> context.getString(R.string.hardware__passphrase_error) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf4b4e680..47fbad5ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -193,6 +193,7 @@ Enter the 6-digit code shown on your hardware device. Pair Device Passphrase + Passphrase protection is turned off on this hardware device. Enable it in Trezor Suite, then try again. You are already watching this passphrase wallet. Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again. Enter <accent>passphrase</accent> diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index f882e3940..ea6299e45 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -115,6 +115,9 @@ class HwWalletRepoTest : BaseUnitTest() { whenever { activityRepo.deleteForWallet(any()) }.thenReturn(Result.success(Unit)) } + private fun passphraseCapableFeatures(): TrezorFeatures = + mock { on { passphraseProtection }.thenReturn(true) } + private fun createRepo() = HwWalletRepo( trezorRepo = trezorRepo, activityRepo = activityRepo, @@ -957,10 +960,11 @@ class HwWalletRepoTest : BaseUnitTest() { @Test fun `connectWithPassphrase opens the hidden wallet and returns its identity`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val features = passphraseCapableFeatures() whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "secret") } .thenReturn(Result.success(mock())) trezorState.value = TrezorState( - connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + connected = ConnectedTrezorDevice(id = "dev1", features = features, walletId = HIDDEN_WALLET_ID), ) val sut = createRepo() @@ -970,13 +974,31 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).setWalletMode(TrezorWalletMode.PASSPHRASE_HOST, "secret") } + @Test + fun `connectWithPassphrase reports a device that cannot open hidden wallets`() = test { + // With passphrase protection off the device ignores the passphrase and reopens the standard + // wallet, so the user would be told they already watch it instead of what is actually wrong. + val features = mock { on { passphraseProtection }.thenReturn(false) } + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = features, walletId = HARDWARE_WALLET_ID), + ) + val sut = createRepo() + + val result = sut.connectWithPassphrase(deviceId = "dev1", passphrase = "secret") + + assertTrue(result.exceptionOrNull() is HwPassphraseDisabledError) + verify(trezorRepo, never()).setWalletMode(any(), any()) + } + @Test fun `connectWithPassphrase reports a passphrase wallet that is already watched`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device, hiddenWallet)) + val features = passphraseCapableFeatures() whenever { trezorRepo.connectWithWalletMode("dev1", TrezorWalletMode.PASSPHRASE_HOST, "secret") } .thenReturn(Result.success(mock())) trezorState.value = TrezorState( - connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HIDDEN_WALLET_ID), + connected = ConnectedTrezorDevice(id = "dev1", features = features, walletId = HIDDEN_WALLET_ID), ) val sut = createRepo()