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..9e20dce97 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -18,4 +18,16 @@ 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, + /** + * 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/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 68c280e44..e322b8b82 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 @@ -114,7 +115,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 +185,124 @@ class HwWalletRepo @Inject constructor( return trezorRepo.connect(deviceId) } - /** Reconnects a known paired device so its session is live for on-device signing. */ + /** + * 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( - deviceId: String, + walletId: String, forceSession: Boolean = false, - ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + trezorRepo.connectKnownDevice(transportDeviceId(walletId), forceSession = forceSession).getOrThrow() + } + } + + /** + * 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 { + 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 + * 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 + } - suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + /** + * 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() + // 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 + + 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(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 +312,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,16 +347,21 @@ 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 { + // 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(), ).getOrElse { if (!it.isTrezorUserCancellation()) { - trezorRepo.disconnectStaleSession(deviceId) + transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) } } throw it } @@ -281,16 +389,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 +415,38 @@ 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 } + // 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.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 +460,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 +508,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 +537,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 +694,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 +783,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) } @@ -704,8 +826,16 @@ 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") + +/** 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") + 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..806037193 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() + } } } @@ -352,12 +373,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 +724,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 +766,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,27 +883,57 @@ class TrezorRepo @Inject constructor( throw AppError("Device not found nearby — is it powered on?") } - suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { - runCatching { + /** + * 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) { + 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. + // 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() + 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) } } } 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 clearCredentialsResult = if (!keepsDevice) { + TrezorDebugLog.log("FORGET", "Clearing credentials...") + trezorTransport.clearDeviceCredential(deviceId) + runSuspendCatching { 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 +1104,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 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 + // 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 +1142,23 @@ 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 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.filter { it.id != known.id } + known + val updated = knownDevices.filterNot { it.isReplacedBy(known, refreshed = previous) } + known saveKnownDevices(updated) _state.update { it.copy(knownDevices = updated.toImmutableList()) } + return known } /** @@ -1159,7 +1247,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,16 +1432,34 @@ 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 +/** + * 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) @@ -1361,10 +1473,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/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 58d55d103..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 { @@ -110,6 +133,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/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/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt new file mode 100644 index 000000000..f175f6c62 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -0,0 +1,205 @@ +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 +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.requiredSize +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.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 +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 +import to.bitkit.ui.components.BottomSheet +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.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 +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.LARGE, isModal = true) + ) + } +} + +@Composable +private fun Content( + isVerifying: Boolean, + modifier: Modifier = Modifier, + onSubmit: (String) -> Unit = {}, + onCancel: () -> Unit = {}, +) { + BlockScreenshots() + + val hazeState = rememberHazeState() + 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") + ) + } + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds() + ) { + Image( + painter = painterResource(R.drawable.shield), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .hazeSource(hazeState) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .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") + ) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content(isVerifying = false) + } +} 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..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,12 +37,11 @@ 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 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,8 +74,16 @@ fun SpendingHwSignScreen( onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, deviceId) }, + 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/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/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 392abc1a2..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 @@ -18,9 +18,13 @@ 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.Logger import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -31,13 +35,19 @@ 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, @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 @@ -153,16 +163,112 @@ 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) + } + + 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 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. + 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, + deviceName = name, + balanceSats = wallet?.balanceSats ?: 0uL, + labelInput = name, + ) + } + 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 deviceId = _uiState.value.pairedDeviceId - if (deviceId == null) { + val state = _uiState.value + if (state.pairedDeviceId == null) { setEffect(HwConnectEffect.Dismiss) return } + // 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 { - hwWalletRepo.setDeviceLabel(deviceId, _uiState.value.labelInput) + 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) } } @@ -194,7 +300,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 { @@ -214,17 +323,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) } @@ -239,10 +354,24 @@ 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 == it.id || deviceId in it.deviceIds } ?: return@collect + val state = _uiState.value + val deviceId = state.pairedDeviceId ?: 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, deviceName = wallet.name, balanceSats = wallet.balanceSats, labelInput = if (labelInitialized) it.labelInput else wallet.name, @@ -262,6 +391,11 @@ 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, + /** 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, @@ -274,6 +408,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..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 @@ -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 @@ -10,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 @@ -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() @@ -110,18 +134,48 @@ private fun Content( 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) + ) + HwPairedButtons( + hazeState = hazeState, + onPassphrase = onPassphrase, + onFinish = onFinish, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 32.dp, vertical = 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 +184,7 @@ private fun Content( private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content( + HwPairedSheet( uiState = HwConnectUiState( deviceName = "Trezor Safe 3", balanceSats = 10_562_411uL, @@ -141,3 +195,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..5ae319289 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPassphraseSheet.kt @@ -0,0 +1,181 @@ +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.requiredSize +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 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 +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.effects.BlockScreenshots +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 = {}, +) { + BlockScreenshots() + + val hazeState = rememberHazeState() + + 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) + .requiredSize(maxWidth * HW_ILLUSTRATION_SIZE_RATIO) + .hazeSource(hazeState) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .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") + ) + } + } + } +} + +@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/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e08c0d3e9..4a9c79bfa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -60,6 +60,8 @@ 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.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -121,7 +123,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 +721,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 +745,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 +773,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 +789,7 @@ class TransferViewModel @Inject constructor( runSuspendCatching { val satsPerVByte = hwFundingSatsPerVByte() hwWalletRepo.composeFundingTransaction( - deviceId = deviceId, + walletId = walletId, address = address, sats = order.feeSat, satsPerVByte = satsPerVByte, @@ -803,18 +805,25 @@ 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 { + // 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() @@ -822,12 +831,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 +844,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 @@ -856,22 +860,67 @@ 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 { + // 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) + } + .onFailure { handleHardwarePassphraseFailure(it, walletId) } + } + } + + /** Backing out of the prompt also drops the reopen it started, so no signature is requested. */ + fun onHwPassphraseDismiss() { + hwTransferSignJob?.cancel() + hwTransferSignJob = null + _spendingUiState.update { it.copy(isHwPassphraseRequired = false, isVerifyingHwPassphrase = 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, - 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 +940,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 +969,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 +989,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 +1022,19 @@ 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 (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 '$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 +1042,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 +1053,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 +1096,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 +1615,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 } @@ -1583,6 +1638,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 1af6e9845..bf4b4e680 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,6 +192,16 @@ 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> + 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. + 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 b92bfa2cd..f882e3940 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -42,9 +42,11 @@ 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 +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds @@ -54,6 +56,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 +81,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))) @@ -118,7 +128,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 +168,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 +210,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 +245,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 +262,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 +271,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 +291,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 +315,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 +325,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 +356,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 +366,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 +391,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 +403,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 +433,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 +445,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 +478,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 +492,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 +516,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 +529,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 +540,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 +552,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 +563,7 @@ class HwWalletRepoTest : BaseUnitTest() { electrumUrl = any(), walletId = eq(derivedWalletId), ) + order.verify(trezorRepo).stopWatcher("legacy-wallet-id|nativeSegwit") } @Test @@ -563,7 +575,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 +591,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 +616,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 +630,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 +656,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 +693,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 +708,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 +733,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 +743,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 +754,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 +765,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 +790,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 +799,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 +824,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,12 +869,266 @@ 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) } + @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 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)) + 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 `connectWithPassphrase opens the hidden wallet and returns its identity`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + 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), + ) + 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.connectWithWalletMode("dev1", 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 `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)) + 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 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.connectWithWalletMode("dev1", 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()).setWalletMode(any(), any()) + 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.connectWithWalletMode("dev1", 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)) + 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( @@ -865,7 +1142,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 +1180,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 +1209,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 +1220,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 +1230,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 +1262,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 +1276,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 +1291,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 +1318,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 +1356,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 +1388,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 +1417,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 +1447,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 +1507,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 +1530,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()) @@ -1299,7 +1579,7 @@ class HwWalletRepoTest : BaseUnitTest() { private fun transactionsChanged( total: ULong, - activities: List, + activities: List = emptyList(), ) = WatcherEvent.TransactionsChanged( balance = walletBalance(total), activities = activities, @@ -1360,7 +1640,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 +1651,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 +1663,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 +1676,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 +1702,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..3ff2a0925 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 @@ -70,6 +71,9 @@ class TrezorRepoTest : BaseUnitTest() { private const val TEST_SIGNATURE = "signature123" private const val TEST_ADDRESS = "bc1qtest" private const val DEVICE_BUSY_MESSAGE = "Your Trezor is busy. Unlock it on the device, then try again." + + /** The address types the store keys account xpubs by. */ + private val ALL_ADDRESS_TYPE_KEYS = listOf("legacy", "nestedSegwit", "nativeSegwit", "taproot") } @get:Rule(order = 1) @@ -146,9 +150,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) } @@ -192,6 +198,8 @@ class TrezorRepoTest : BaseUnitTest() { xpubs: Map = emptyMap(), customLabel: String? = null, walletId: String = "wallet-id", + passphraseProtected: Boolean = false, + trezorDeviceId: String? = null, ) = KnownDevice( id = id, name = name, @@ -203,6 +211,8 @@ class TrezorRepoTest : BaseUnitTest() { xpubs = xpubs, customLabel = customLabel, walletId = walletId, + passphraseProtected = passphraseProtected, + trezorDeviceId = trezorDeviceId, ) // region initialize @@ -759,10 +769,157 @@ 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 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 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'")) + 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 + // 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 +937,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 +952,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, @@ -1786,6 +1943,158 @@ 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 `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 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; + // 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") + 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 diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 254089fb7..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,50 @@ 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 + // 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 8c6c0f280..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 @@ -11,9 +11,11 @@ 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 +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -21,9 +23,12 @@ 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 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 +53,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, @@ -268,8 +274,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 +284,253 @@ class HwConnectViewModelTest : BaseUnitTest() { cancelAndIgnoreRemainingEvents() } - verify(hwWalletRepo).setDeviceLabel("dev1", "My Cold Wallet") + 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() + 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 `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 `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 + // 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 `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 + // 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() + 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() { @@ -308,19 +561,26 @@ 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 { 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." } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 7b6c796b1..025b223cc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -67,6 +67,8 @@ 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.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -116,7 +118,7 @@ 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)) + whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) @@ -255,7 +257,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 +272,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 +281,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 +297,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 +317,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 +367,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 +571,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 +609,123 @@ class TransferViewModelTest : BaseUnitTest() { eq(FEE_RATE), eq(HARDWARE_WALLET_ID), ) - verify(hwWalletRepo).ensureConnected(DEVICE_ID) + 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 `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() + 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 `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() + 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 @@ -628,8 +746,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 +755,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 +771,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 +789,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 +801,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 +813,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 +842,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 +870,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 +879,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 +894,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 +918,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 +945,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 +955,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 +971,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 +982,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 +1000,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 +1009,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 +1028,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 +1050,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 +1061,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 +1089,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 +1103,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 +1111,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 +1134,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 +1152,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 +1190,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 +1237,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 +1249,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 +1265,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 +1296,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 +1327,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 +1622,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,13 +1694,13 @@ 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." 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" diff --git a/changelog.d/next/1142.added.md b/changelog.d/next/1142.added.md new file mode 100644 index 000000000..54ed99bef --- /dev/null +++ b/changelog.d/next/1142.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..e115378af --- /dev/null +++ b/journeys/hardware-wallet/passphrase-duplicate.xml @@ -0,0 +1,42 @@ + + + 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 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 + + + 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..b89042d22 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-pairing.xml @@ -0,0 +1,68 @@ + + + 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 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 + + + 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..b3333bdc0 --- /dev/null +++ b/journeys/hardware-wallet/passphrase-transfer-to-spending.xml @@ -0,0 +1,74 @@ + + + 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 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 + + + 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 + + +