From b537530b6234dace9b4a5780b12269b1720d9636 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Aug 2026 11:28:25 -0300 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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