From c1587286f7425d81a33a74263d94fc11fe4c5153 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 10:23:02 -0500 Subject: [PATCH 1/4] feat: add trezor receive --- .../java/to/bitkit/models/HardwareWallet.kt | 17 ++ .../to/bitkit/repositories/HwWalletRepo.kt | 110 +++++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 5 +- .../java/to/bitkit/ui/components/SheetHost.kt | 5 +- .../wallets/receive/EditInvoiceScreen.kt | 76 ++++--- .../wallets/receive/HwReceiveViewModel.kt | 203 ++++++++++++++++++ .../wallets/receive/ReceiveInvoiceUtils.kt | 13 ++ .../wallets/receive/ReceiveQrScreen.kt | 132 +++++++++++- .../screens/wallets/receive/ReceiveSheet.kt | 37 +++- .../ui/screens/wallets/receive/ReceiveTab.kt | 5 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 + app/src/main/res/values/strings.xml | 3 + .../bitkit/repositories/HwWalletRepoTest.kt | 147 +++++++++++-- .../ui/screens/trezor/TrezorViewModelTest.kt | 9 + .../wallets/receive/HwReceiveViewModelTest.kt | 113 ++++++++++ .../receive/ReceiveInvoiceUtilsTest.kt | 32 +++ changelog.d/next/1189.added.md | 1 + gradle/libs.versions.toml | 2 +- journeys/hardware-wallet/README.md | 10 +- journeys/hardware-wallet/receive-onchain.xml | 34 +++ 20 files changed, 884 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt create mode 100644 changelog.d/next/1189.added.md create mode 100644 journeys/hardware-wallet/receive-onchain.xml diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index e0a0689397..df54fdc273 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.TrezorScriptType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -41,6 +42,14 @@ data class HwWalletReceivedTx( val walletId: String, ) +/** The next unused external address for a paired hardware-wallet account. */ +@Immutable +data class HwReceiveAddress( + val address: String, + val path: String, + val addressType: HwFundingAddressType, +) + sealed interface HwFundingAccount { val vendor: HwWalletVendor val xpub: String @@ -99,6 +108,14 @@ enum class HwFundingAddressType( val accountType: AccountType get() = addressType.toAccountType() + val trezorScriptType: TrezorScriptType + get() = when (this) { + LEGACY -> TrezorScriptType.SPEND_ADDRESS + NESTED_SEGWIT -> TrezorScriptType.SPEND_P2SH_WITNESS + NATIVE_SEGWIT -> TrezorScriptType.SPEND_WITNESS + TAPROOT -> TrezorScriptType.SPEND_TAPROOT + } + companion object { val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE } } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 4a46ae3a88..8e804a1204 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -15,6 +15,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -47,6 +48,7 @@ import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction +import to.bitkit.models.HwReceiveAddress import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice @@ -330,7 +332,7 @@ class HwWalletRepo @Inject constructor( } val balanceSats = _watcherData.value .values - .filter { it.addressType == addressType.settingsKey && it.walletId == walletId } + .filter { it.addressType == addressType && it.walletId == walletId } .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwFundingAccount.Trezor( xpub = xpub, @@ -340,6 +342,82 @@ class HwWalletRepo @Inject constructor( } } + /** Resolves the next unused external address from watcher state, falling back to an account scan. */ + suspend fun getReceiveAddress( + walletId: String, + addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + watcherReceiveAddress(walletId, addressType)?.let { return@runSuspendCatching it } + val account = getFundingAccount(walletId, addressType).getOrThrow() + val accountInfo = trezorRepo.getAccountInfo( + extendedKey = account.xpub, + network = Env.network.toCoreNetwork(), + scriptType = account.accountType, + ).getOrThrow() + val unused = requireNotNull(accountInfo.account.addresses.unused.firstOrNull()) { + "No unused external address returned for hardware wallet '$walletId'" + } + val scannedAddress = HwReceiveAddress( + address = unused.address, + path = unused.path, + addressType = addressType, + ) + watcherReceiveAddress(walletId, addressType) ?: scannedAddress + } + } + + fun observeReceiveAddress( + walletId: String, + addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, + ): Flow = _watcherData + .map { watcherData -> watcherData.receiveAddress(walletId, addressType) } + .distinctUntilChanged() + + private fun watcherReceiveAddress( + walletId: String, + addressType: HwFundingAddressType, + ): HwReceiveAddress? = _watcherData.value.receiveAddress(walletId, addressType) + + /** Displays the exact address currently shown by Bitkit on the device and rejects a mismatch. */ + suspend fun verifyReceiveAddress( + walletId: String, + receiveAddress: HwReceiveAddress, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + suspend fun readOnDevice() = trezorRepo.getAddress( + path = receiveAddress.path, + showOnTrezor = true, + scriptType = receiveAddress.addressType.trezorScriptType, + coin = Env.network.toTrezorCoinType(), + ).getOrThrow() + + ensureConnected(walletId).getOrThrow() + val firstAttempt = runSuspendCatching { readOnDevice() } + val firstError = firstAttempt.exceptionOrNull() + val response = if (firstError == null) { + firstAttempt.getOrThrow() + } else { + if (!firstError.isTrezorSessionFailure()) throw firstError + disconnectStaleSession(walletId).getOrThrow() + ensureConnected(walletId).getOrThrow() + runSuspendCatching { readOnDevice() } + .onFailure { + if (it.isTrezorSessionFailure()) { + disconnectStaleSession(walletId).getOrThrow() + } + } + .getOrThrow() + } + if (response.address != receiveAddress.address) { + throw HwReceiveAddressMismatchError( + "Address verification failed: Trezor returned '${response.address}' for " + + "'${receiveAddress.path}', expected '${receiveAddress.address}'" + ) + } + } + } + /** Composes the exact on-chain funding payment before prompting for the Trezor signature. */ suspend fun composeFundingTransaction( walletId: String, @@ -585,7 +663,7 @@ class HwWalletRepo @Inject constructor( val ids = devices.map { it.id }.toSet() val walletWatchers = watcherData.values.filter { it.walletId == walletId } val fundingBalanceSats = walletWatchers - .filter { it.addressType == HwFundingAddressType.DEFAULT.settingsKey } + .filter { it.addressType == HwFundingAddressType.DEFAULT } .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwWallet( id = walletId, @@ -649,11 +727,13 @@ class HwWalletRepo @Inject constructor( val transactionDetails = event.transactionDetails .filter { it.walletId == walletId } .toImmutableList() + val addressType = watcherId.toFundingAddressType() ?: return@withLock emptyList() val watcher = HwWatcherData( walletId = walletId, - addressType = watcherId.toAddressTypeKey(), + addressType = addressType, balanceSats = event.balance.total, activities = activities, + receiveAddress = event.toReceiveAddress(addressType), ) _watcherData.update { it + (watcherId to watcher) } val previousIds = persistedActivityIds.getOrPut(watcherId) { @@ -686,7 +766,6 @@ class HwWalletRepo @Inject constructor( val persistedWatcher = watcher.copy(activities = immutablePersistedActivities) val updatedWatcherData = _watcherData.value + (watcherId to persistedWatcher) _watcherData.update { updatedWatcherData } - persistedActivityIds[watcherId] = persistedActivities.map { it.scopedId() }.toSet() buildReceivedTxs(previousIds, persistedActivities, updatedWatcherData) } @@ -915,6 +994,9 @@ class HwWalletRepo @Inject constructor( private fun String.toWalletId(): String = substringBefore(WATCHER_ID_SEPARATOR) private fun String.toAddressTypeKey(): String = substringAfter(WATCHER_ID_SEPARATOR) + + private fun String.toFundingAddressType(): HwFundingAddressType? = + HwFundingAddressType.entries.firstOrNull { it.settingsKey == toAddressTypeKey() } } private data class WatcherSettings( @@ -957,6 +1039,8 @@ class HwPassphraseRequiredError : AppError("Passphrase needed to reopen this wal /** The entered passphrase opened a different wallet than the one being signed from. */ class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet") +class HwReceiveAddressMismatchError(message: String) : AppError(message) + /** * A removal asked to keep the wallet's backup data, but its tags could not be read. Raised before * anything is deleted, so the wallet is untouched and the removal can be retried or repeated without @@ -966,9 +1050,25 @@ class HwBackupDataUnreadableError(cause: Throwable) : AppError("Could not read t private data class HwWatcherData( val walletId: String, - val addressType: String, + val addressType: HwFundingAddressType, val balanceSats: ULong, val activities: ImmutableList, + val receiveAddress: HwReceiveAddress, +) + +private fun Map.receiveAddress( + walletId: String, + addressType: HwFundingAddressType, +): HwReceiveAddress? = values.firstOrNull { + it.walletId == walletId && it.addressType == addressType +}?.receiveAddress + +private fun WatcherEvent.TransactionsChanged.toReceiveAddress( + addressType: HwFundingAddressType, +) = HwReceiveAddress( + address = nextUnusedExternalAddress.address, + path = nextUnusedExternalAddress.path, + addressType = addressType, ) private data class HwSnapshot( diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b34954e346..46113e75bd 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -512,6 +512,7 @@ fun ContentView( ReceiveSheet( appViewModel = appViewModel, startRoute = sheet.route, + hardwareWalletId = sheet.hardwareWalletId, walletState = walletState, isOffline = connectivityState != ConnectivityState.CONNECTED, navigateToExternalConnection = { @@ -666,7 +667,9 @@ fun ContentView( onSendClick = { appViewModel.showSheet(Sheet.Send(hardwareWalletId = currentHardwareWalletId)) }, - onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, + onReceiveClick = { + appViewModel.showSheet(Sheet.Receive(hardwareWalletId = currentHardwareWalletId)) + }, onScanClick = { appViewModel.showScannerSheet() }, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 7fa8f523ce..4d0443ed30 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -58,7 +58,10 @@ sealed interface Sheet { val route: SendRoute = SendRoute.Recipient, val hardwareWalletId: String? = null, ) : Sheet - data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet + data class Receive( + val route: ReceiveRoute = ReceiveRoute.QR, + val hardwareWalletId: String? = null, + ) : Sheet data object PaymentRequests : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 1df8296e3d..48010f149e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -83,6 +83,8 @@ fun EditInvoiceScreen( onClickPaymentRequest: (amountSats: ULong, note: String) -> Unit, onBack: () -> Unit, navigateReceiveConfirm: (CjitEntryDetails) -> Unit, + onchainOnly: Boolean = false, + updateOnchainInvoice: (ULong?) -> Unit = {}, currencies: CurrencyState = LocalCurrencies.current, editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { @@ -92,7 +94,8 @@ fun EditInvoiceScreen( val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val isLoading by editInvoiceVM.isLoading.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { + LaunchedEffect(onchainOnly) { + if (onchainOnly) return@LaunchedEffect editInvoiceVM.editInvoiceEffect.collect { effect -> val receiveSats = amountInputUiState.sats.toULong() when (effect) { @@ -145,7 +148,14 @@ fun EditInvoiceScreen( } }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { editInvoiceVM.onClickContinue() }, + onContinueGeneral = { + if (onchainOnly) { + updateOnchainInvoice(amountInputUiState.sats.toULong()) + onBack() + } else { + editInvoiceVM.onClickContinue() + } + }, isLoading = isLoading, onClickAddTag = onClickAddTag, onClickTag = onClickTag, @@ -154,6 +164,7 @@ fun EditInvoiceScreen( onClickPaymentRequest = { onClickPaymentRequest(amountInputUiState.sats.toULong(), walletUiState.bip21Description) }, + allowsTags = !onchainOnly, ) } @@ -163,6 +174,7 @@ fun EditInvoiceContent( amountInputViewModel: AmountInputViewModel, noteText: String, isSoftKeyboardVisible: Boolean, + allowsTags: Boolean = true, keyboardVisible: Boolean, tags: ImmutableList, onBack: () -> Unit, @@ -314,38 +326,40 @@ fun EditInvoiceContent( ) VerticalSpacer(16.dp) - Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) - VerticalSpacer(8.dp) + if (allowsTags) { + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + VerticalSpacer(8.dp) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - tags.forEach { tagText -> - TagButton( - text = tagText, - displayIconClose = true, - onClick = { onClickTag(tagText) }, - ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + tags.forEach { tagText -> + TagButton( + text = tagText, + displayIconClose = true, + onClick = { onClickTag(tagText) }, + ) + } } + PrimaryButton( + text = stringResource(R.string.wallet__tags_add), + size = ButtonSize.Small, + onClick = { onClickAddTag() }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + tint = Colors.Brand + ) + }, + fullWidth = false, + modifier = Modifier.testTag("TagsAdd") + ) } - PrimaryButton( - text = stringResource(R.string.wallet__tags_add), - size = ButtonSize.Small, - onClick = { onClickAddTag() }, - icon = { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - tint = Colors.Brand - ) - }, - fullWidth = false, - modifier = Modifier.testTag("TagsAdd") - ) FillHeight() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt new file mode 100644 index 0000000000..074ab7ef6b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt @@ -0,0 +1,203 @@ +package to.bitkit.ui.screens.wallets.receive + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import to.bitkit.R +import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorFirmwareError +import to.bitkit.ext.isTrezorUserCancellation +import to.bitkit.models.HwReceiveAddress +import to.bitkit.models.Toast +import to.bitkit.repositories.HwPassphraseMismatchError +import to.bitkit.repositories.HwPassphraseRequiredError +import to.bitkit.repositories.HwReceiveAddressMismatchError +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class HwReceiveViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val hwWalletRepo: HwWalletRepo, +) : ViewModel() { + private companion object { + val VERIFY_TIMEOUT = 120.seconds + } + + val wallets = hwWalletRepo.wallets + + private val _uiState = MutableStateFlow(HwReceiveUiState()) + val uiState = _uiState.asStateFlow() + + private var loadJob: Job? = null + private var addressUpdatesJob: Job? = null + private var verifyJob: Job? = null + private var passphraseJob: Job? = null + + fun loadAddress(walletId: String) { + val state = _uiState.value + if (state.walletId == walletId && (state.address != null || state.isLoadingAddress)) return + loadJob?.cancel() + addressUpdatesJob?.cancel() + _uiState.update { HwReceiveUiState(walletId = walletId, isLoadingAddress = true) } + addressUpdatesJob = viewModelScope.launch { + hwWalletRepo.observeReceiveAddress(walletId).collect { address -> + if (address != null && _uiState.value.walletId == walletId) { + _uiState.update { + it.copy(address = address, isLoadingAddress = false, addressLoadFailed = false) + } + } + } + } + loadJob = viewModelScope.launch { + hwWalletRepo.getReceiveAddress(walletId) + .onSuccess { address -> + if (_uiState.value.walletId == walletId) { + _uiState.update { it.copy(address = address, isLoadingAddress = false) } + } + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + if (_uiState.value.walletId == walletId) { + _uiState.update { it.copy(isLoadingAddress = false, addressLoadFailed = true) } + } + } + } + } + + fun retryAddress() { + val walletId = _uiState.value.walletId ?: return + _uiState.update { it.copy(address = null, addressLoadFailed = false) } + loadAddress(walletId) + } + + fun verifyAddress() { + val state = _uiState.value + val walletId = state.walletId ?: return + val address = state.address ?: return + if (state.isVerifyingAddress || verifyJob?.isActive == true) return + + _uiState.update { it.copy(isVerifyingAddress = true) } + verifyJob = viewModelScope.launch { + try { + if (hwWalletRepo.needsPassphrase(walletId)) { + _uiState.update { it.copy(isPassphraseRequired = true) } + return@launch + } + runCatching { + withTimeout(VERIFY_TIMEOUT) { + hwWalletRepo.verifyReceiveAddress(walletId, address).getOrThrow() + } + }.onFailure { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + handleVerifyFailure(it) + } + } finally { + _uiState.update { it.copy(isVerifyingAddress = false) } + verifyJob = null + } + } + } + + fun submitPassphrase(passphrase: String) { + val state = _uiState.value + val walletId = state.walletId ?: return + if (passphrase.isEmpty() || !state.isPassphraseRequired || state.isVerifyingPassphrase) return + + _uiState.update { it.copy(isVerifyingPassphrase = true) } + passphraseJob = viewModelScope.launch { + try { + hwWalletRepo.reconnectWithPassphrase(walletId, passphrase) + .onSuccess { + _uiState.update { it.copy(isPassphraseRequired = false) } + verifyAddress() + } + .onFailure { error -> + if (error is HwPassphraseMismatchError) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__passphrase_mismatch), + ) + } else { + handleVerifyFailure(error) + } + } + } finally { + _uiState.update { it.copy(isVerifyingPassphrase = false) } + passphraseJob = null + } + } + } + + fun dismissPassphrase() { + passphraseJob?.cancel() + passphraseJob = null + _uiState.update { it.copy(isPassphraseRequired = false, isVerifyingPassphrase = false) } + } + + fun cancel() { + loadJob?.cancel() + addressUpdatesJob?.cancel() + verifyJob?.cancel() + passphraseJob?.cancel() + loadJob = null + addressUpdatesJob = null + verifyJob = null + passphraseJob = null + _uiState.update { HwReceiveUiState() } + } + + private suspend fun handleVerifyFailure(error: Throwable) { + when { + error.isTrezorUserCancellation() -> Unit + generateSequence(error) { it.cause }.any { it is HwPassphraseRequiredError } -> { + _uiState.update { it.copy(isPassphraseRequired = true) } + } + error.isTrezorDeviceBusy() -> ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.hardware__device_busy), + ) + error.isTrezorFirmwareError() -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__connect_error), + ) + error is TimeoutCancellationException -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__payment_timeout), + ) + error is HwReceiveAddressMismatchError -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__verify_address_error), + ) + else -> ToastEventBus.send(error) + } + } +} + +@Immutable +data class HwReceiveUiState( + val walletId: String? = null, + val address: HwReceiveAddress? = null, + val isLoadingAddress: Boolean = false, + val addressLoadFailed: Boolean = false, + val isVerifyingAddress: Boolean = false, + val isPassphraseRequired: Boolean = false, + val isVerifyingPassphrase: Boolean = false, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index 5c520fef6f..c6b3673daf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets.receive import to.bitkit.R +import to.bitkit.utils.Bip21Utils /** * Returns the appropriate invoice/address for the selected tab. @@ -20,6 +21,9 @@ fun getInvoiceForTab( cjitInvoice: String?, isNodeRunning: Boolean, onchainAddress: String, + hardwareAddress: String = "", + hardwareAmountSats: ULong? = null, + hardwareMessage: String = "", ): String { return when (tab) { ReceiveTab.SAVINGS -> { @@ -36,6 +40,14 @@ fun getInvoiceForTab( cjitInvoice?.takeIf { it.isNotEmpty() && isNodeRunning } ?: bolt11.takeIf { isNodeRunning }.orEmpty() } + + ReceiveTab.TREZOR -> hardwareAddress.takeIf(String::isNotBlank)?.let { address -> + Bip21Utils.buildBip21Url( + bitcoinAddress = address, + amountSats = hardwareAmountSats, + message = hardwareMessage, + ) + }.orEmpty() } } @@ -93,5 +105,6 @@ fun getQrLogoResource(tab: ReceiveTab): Int { ReceiveTab.SAVINGS -> R.drawable.ic_btc_circle ReceiveTab.AUTO -> R.drawable.ic_unified_circle ReceiveTab.SPENDING -> R.drawable.ic_ln_circle + ReceiveTab.TREZOR -> R.drawable.ic_btc_circle_blue } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 289a6f2587..a9c5fd26f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.keepScreenOn @@ -91,8 +92,14 @@ fun ReceiveQrScreen( lightningState: LightningState, onClickEditInvoice: () -> Unit, onClickReceiveCjit: () -> Unit, + onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, + hardwareWalletId: String? = null, + hardwareReceiveState: HwReceiveUiState = HwReceiveUiState(), + onLoadHardwareAddress: (String) -> Unit = {}, + onRetryHardwareAddress: () -> Unit = {}, + onVerifyHardwareAddress: () -> Unit = {}, ) { SetMaxBrightness() @@ -101,8 +108,11 @@ fun ReceiveQrScreen( var showDetails by remember { mutableStateOf(false) } - val visibleTabs = remember(hasUsableChannels) { + val visibleTabs = remember(hasUsableChannels, hardwareWalletId) { buildList { + if (hardwareWalletId != null) { + add(ReceiveTab.TREZOR) + } add(ReceiveTab.SAVINGS) if (hasUsableChannels) { add(ReceiveTab.AUTO) @@ -118,6 +128,9 @@ fun ReceiveQrScreen( walletState.onchainAddress, cjitInvoice, lightningState.nodeLifecycleState, + hardwareReceiveState.address, + walletState.bip21AmountSats, + walletState.bip21Description, ) { visibleTabs.associateWith { tab -> getInvoiceForTab( @@ -127,13 +140,18 @@ fun ReceiveQrScreen( cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), onchainAddress = walletState.onchainAddress, + hardwareAddress = hardwareReceiveState.address?.address.orEmpty(), + hardwareAmountSats = walletState.bip21AmountSats, + hardwareMessage = walletState.bip21Description, ) } } // LazyRow state with snap behavior val scope = rememberCoroutineScope() - val lazyListState = rememberLazyListState() + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = visibleTabs.indexOf(initialTab ?: ReceiveTab.SAVINGS).coerceAtLeast(0), + ) val snapBehavior = rememberSnapFlingBehavior( lazyListState = lazyListState, @@ -144,8 +162,16 @@ fun ReceiveQrScreen( var selectedTab by remember { mutableStateOf(initialTab ?: ReceiveTab.SAVINGS) } + var hasAppliedInitialTab by remember { mutableStateOf(false) } - LaunchedEffect(visibleTabs) { + LaunchedEffect(visibleTabs, initialTab) { + if (!hasAppliedInitialTab) { + hasAppliedInitialTab = true + initialTab?.takeIf { it in visibleTabs }?.let { requestedTab -> + selectedTab = requestedTab + lazyListState.scrollToItem(visibleTabs.indexOf(requestedTab)) + } + } if (selectedTab !in visibleTabs) { selectedTab = visibleTabs.first() } @@ -164,7 +190,7 @@ fun ReceiveQrScreen( // Auto-switch to AUTO tab when it becomes available for the first time LaunchedEffect(hasUsableChannels) { - if (hasUsableChannels && visibleTabs.contains(ReceiveTab.AUTO)) { + if (initialTab == null && hasUsableChannels && visibleTabs.contains(ReceiveTab.AUTO)) { val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) if (autoIndex != -1) { lazyListState.animateScrollToItem(autoIndex) @@ -184,6 +210,13 @@ fun ReceiveQrScreen( } } + LaunchedEffect(selectedTab, hardwareWalletId) { + showDetails = false + if (selectedTab == ReceiveTab.TREZOR && hardwareWalletId != null) { + onLoadHardwareAddress(hardwareWalletId) + } + } + val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { !hasUsableChannels && lightningState.nodeLifecycleState.isRunning() && @@ -210,6 +243,7 @@ fun ReceiveQrScreen( haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) val newIndex = visibleTabs.indexOf(tab) selectedTab = tab + showDetails = false scope.launch { lazyListState.animateScrollToItem(newIndex) } @@ -247,6 +281,15 @@ fun ReceiveQrScreen( ) } + tab == ReceiveTab.TREZOR && hardwareReceiveState.address == null -> { + HardwareAddressLoadingView( + isLoading = hardwareReceiveState.isLoadingAddress, + hasFailed = hardwareReceiveState.addressLoadFailed, + onRetry = onRetryHardwareAddress, + modifier = Modifier.weight(1f), + ) + } + showDetails -> { ReceiveDetailsView( tab = tab, @@ -254,6 +297,10 @@ fun ReceiveQrScreen( cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), onClickEditInvoice = onClickEditInvoice, + onClickHardwareEditInvoice = onClickHardwareEditInvoice, + hardwareAddress = hardwareReceiveState.address?.address, + isVerifyingHardwareAddress = hardwareReceiveState.isVerifyingAddress, + onVerifyHardwareAddress = onVerifyHardwareAddress, modifier = Modifier.weight(1f) ) } @@ -266,6 +313,9 @@ fun ReceiveQrScreen( walletState.onchainAddress, ) + ReceiveTab.TREZOR -> invoice.takeIf { '?' in it } + ?: hardwareReceiveState.address?.address.orEmpty() + else -> invoice } @@ -273,7 +323,9 @@ fun ReceiveQrScreen( uri = invoice, copyText = copyText, qrLogoPainter = painterResource(getQrLogoResource(tab)), - onClickEditInvoice = if (cjitInvoice.isNullOrEmpty()) { + onClickEditInvoice = if (tab == ReceiveTab.TREZOR) { + onClickHardwareEditInvoice + } else if (cjitInvoice.isNullOrEmpty()) { onClickEditInvoice } else { onClickReceiveCjit @@ -339,6 +391,7 @@ fun ReceiveQrScreen( BottomButtonVariant.SHOW_DETAILS -> TertiaryButton( text = stringResource(R.string.wallet__receive_show_details), onClick = { showDetails = true }, + enabled = selectedTab != ReceiveTab.TREZOR || hardwareReceiveState.address != null, fullWidth = true, modifier = Modifier .padding(horizontal = 16.dp) @@ -503,6 +556,10 @@ private fun ReceiveDetailsView( cjitInvoice: String?, isNodeRunning: Boolean, onClickEditInvoice: () -> Unit, + onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, + hardwareAddress: String? = null, + isVerifyingHardwareAddress: Boolean = false, + onVerifyHardwareAddress: () -> Unit = {}, modifier: Modifier = Modifier, ) { Card( @@ -575,11 +632,65 @@ private fun ReceiveDetailsView( } } } + + ReceiveTab.TREZOR -> { + hardwareAddress?.let { address -> + CopyAddressCard( + title = stringResource(R.string.wallet__receive_bitcoin_invoice), + address = address, + type = CopyAddressType.ONCHAIN, + onClickEditInvoice = onClickHardwareEditInvoice, + accentColor = Colors.Blue, + testTag = "ReceiveHardwareAddress", + ) + } + VerticalSpacer(16.dp) + PrimaryButton( + text = stringResource(R.string.hardware__verify_address), + enabled = hardwareAddress != null, + isLoading = isVerifyingHardwareAddress, + onClick = onVerifyHardwareAddress, + color = Colors.Blue, + enableGradient = false, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("HardwareVerifyAddress") + ) + } } } } } +@Composable +private fun HardwareAddressLoadingView( + isLoading: Boolean, + hasFailed: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.fillMaxWidth(), + ) { + if (hasFailed) { + BodyM( + text = stringResource(R.string.hardware__receive_address_error), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + TertiaryButton( + text = stringResource(R.string.common__try_again), + onClick = onRetry, + fullWidth = false, + ) + } else if (isLoading) { + GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } +} + private enum class BottomButtonVariant { CJIT, SHOW_QR, SHOW_DETAILS } enum class CopyAddressType { ONCHAIN, LIGHTNING } @@ -593,11 +704,16 @@ private fun CopyAddressCard( onClickEditInvoice: () -> Unit, body: String? = null, testTag: String? = null, + accentColor: Color? = null, ) { val context = LocalContext.current val tooltipState = rememberTooltipState() val coroutineScope = rememberCoroutineScope() + val buttonAccentColor = accentColor ?: when (type) { + CopyAddressType.ONCHAIN -> Colors.Brand + CopyAddressType.LIGHTNING -> Colors.Purple + } Column( modifier = Modifier @@ -625,7 +741,7 @@ private fun CopyAddressCard( Icon( painter = painterResource(R.drawable.ic_pencil_simple), contentDescription = null, - tint = if (type == CopyAddressType.ONCHAIN) Colors.Brand else Colors.Purple, + tint = buttonAccentColor, modifier = Modifier.size(18.dp) ) }, @@ -650,7 +766,7 @@ private fun CopyAddressCard( Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = null, - tint = if (type == CopyAddressType.ONCHAIN) Colors.Brand else Colors.Purple, + tint = buttonAccentColor, modifier = Modifier.size(18.dp) ) }, @@ -666,7 +782,7 @@ private fun CopyAddressCard( Icon( painter = painterResource(R.drawable.ic_share), contentDescription = null, - tint = if (type == CopyAddressType.ONCHAIN) Colors.Brand else Colors.Purple, + tint = buttonAccentColor, modifier = Modifier.size(18.dp) ) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 020aba5a78..58d284c2d5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,6 +36,7 @@ import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestSentScreen +import to.bitkit.ui.screens.transfer.hardware.HwPassphrasePromptSheet import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.ScreenDeepLinks @@ -56,9 +58,11 @@ fun ReceiveSheet( walletState: WalletState, isOffline: Boolean, startRoute: ReceiveRoute = ReceiveRoute.QR, + hardwareWalletId: String? = null, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), paymentRequestAmountViewModel: AmountInputViewModel = hiltViewModel(key = "PaymentRequestAmount"), settingsViewModel: SettingsViewModel = hiltViewModel(), + hwReceiveViewModel: HwReceiveViewModel = hiltViewModel(), ) { val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() @@ -70,6 +74,7 @@ fun ReceiveSheet( val cjitInvoice = remember { mutableStateOf(null) } val showCreateCjit = remember { mutableStateOf(false) } val cjitEntryDetails = remember { mutableStateOf(null) } + val isEditingHardwareInvoice = remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() var paymentRequestDraft by remember { @@ -82,6 +87,13 @@ fun ReceiveSheet( ) } var createdPaymentRequest by remember { mutableStateOf(null) } + val hardwareWallets by hwReceiveViewModel.wallets.collectAsStateWithLifecycle() + val hwReceiveState by hwReceiveViewModel.uiState.collectAsStateWithLifecycle() + val selectedHardwareWalletId = hardwareWalletId ?: hardwareWallets.singleOrNull()?.id + + DisposableEffect(hwReceiveViewModel) { + onDispose(hwReceiveViewModel::cancel) + } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -120,7 +132,20 @@ fun ReceiveSheet( navController.navigateTo(ReceiveRoute.Amount) } }, - onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, + onClickEditInvoice = { + isEditingHardwareInvoice.value = false + navController.navigateTo(ReceiveRoute.EditInvoice) + }, + onClickHardwareEditInvoice = { + isEditingHardwareInvoice.value = true + navController.navigateTo(ReceiveRoute.EditInvoice) + }, + initialTab = if (hardwareWalletId != null) ReceiveTab.TREZOR else null, + hardwareWalletId = selectedHardwareWalletId, + hardwareReceiveState = hwReceiveState, + onLoadHardwareAddress = hwReceiveViewModel::loadAddress, + onRetryHardwareAddress = hwReceiveViewModel::retryAddress, + onVerifyHardwareAddress = hwReceiveViewModel::verifyAddress, ) } composableWithDefaultTransitions { @@ -278,6 +303,8 @@ fun ReceiveSheet( cjitEntryDetails.value = entry navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) }, + onchainOnly = isEditingHardwareInvoice.value, + updateOnchainInvoice = wallet::setBip21AmountSats, ) } composableWithDefaultTransitions { @@ -296,6 +323,14 @@ fun ReceiveSheet( } } + if (hwReceiveState.isPassphraseRequired) { + HwPassphrasePromptSheet( + isVerifying = hwReceiveState.isVerifyingPassphrase, + onSubmit = hwReceiveViewModel::submitPassphrase, + onDismiss = hwReceiveViewModel::dismissPassphrase, + ) + } + AnimatedVisibility( visible = isOffline, enter = fadeIn(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt index 1d1c8611c0..a862cc6aea 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveTab.kt @@ -10,7 +10,8 @@ import to.bitkit.ui.theme.Colors enum class ReceiveTab : TabItem { SAVINGS, AUTO, - SPENDING; + SPENDING, + TREZOR; override val uiText: String @Composable @@ -18,6 +19,7 @@ enum class ReceiveTab : TabItem { SAVINGS -> stringResource(R.string.wallet__receive_tab_savings) AUTO -> stringResource(R.string.wallet__receive_tab_auto) SPENDING -> stringResource(R.string.wallet__receive_tab_spending) + TREZOR -> stringResource(R.string.hardware__device_model_trezor) } val accentColor: Color @@ -25,5 +27,6 @@ enum class ReceiveTab : TabItem { SAVINGS -> Colors.Brand AUTO -> Colors.Brand SPENDING -> Colors.Purple + TREZOR -> Colors.Blue } } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 56a8c8feac..aa4a52d69b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -580,6 +580,10 @@ class WalletViewModel @Inject constructor( walletRepo.setBip21Description(newText) } + fun setBip21AmountSats(amountSats: ULong?) { + walletRepo.setBip21AmountSats(amountSats?.takeIf { it > 0uL }) + } + suspend fun handleHideBalanceOnOpen() { val hideBalanceOnOpen = settingsStore.data.map { it.hideBalanceOnOpen }.first() if (hideBalanceOnOpen) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cce3f106cd..7d8df22574 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,7 @@ 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 + Could not load the hardware wallet address. Remove %1$s Keep name and tags in backup Don\'t worry, your funds are safe and your coins won\'t be deleted. Bitkit will simply stop displaying the amounts in the wallet. @@ -216,6 +217,8 @@ Bitcoin address required Open Trezor Connect Sign With Device + Verify on Device + Address verification failed. Check the address on your device and try again. Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. Funds\n<accent>availability</accent> Balance diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index a376c99855..1e3b8f38c1 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -1,13 +1,18 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.AccountAddresses +import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressInfo +import com.synonym.bitkitcore.ComposeAccount import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx @@ -37,8 +42,10 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.create +import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction +import to.bitkit.models.HwReceiveAddress import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType @@ -61,6 +68,11 @@ class HwWalletRepoTest : BaseUnitTest() { private companion object { const val HARDWARE_WALLET_ID = "hardware-wallet" const val HIDDEN_WALLET_ID = "hidden-wallet" + val WATCHER_RECEIVE_ADDRESS = AddressInfo( + address = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8", + path = "m/84'/1'/0'/0/0", + transfers = 0u, + ) } private val trezorRepo = mock() @@ -187,6 +199,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 850_000u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -312,6 +325,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) watcherEvents.emit( @@ -321,6 +335,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -345,6 +360,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 2u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -366,6 +382,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) watcherEvents.emit( @@ -376,6 +393,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.TAPROOT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -407,6 +425,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) watcherEvents.emit( @@ -419,6 +438,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -444,6 +464,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) watcherEvents.emit( @@ -456,6 +477,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.TAPROOT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -484,6 +506,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) val firstTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp @@ -496,6 +519,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) val refreshedTimestamp = (sut.wallets.value.single().activities.single() as Activity.Onchain).v1.timestamp @@ -660,31 +684,23 @@ class HwWalletRepoTest : BaseUnitTest() { // Baseline: full history delivered on watcher start must not emit. watcherEvents.emit( - "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 100uL), + "hardware-wallet|nativeSegwit" to transactionsChanged( + total = 100uL, activities = listOf(watcherActivity(amount = 100uL)), - transactionDetails = emptyList(), - txCount = 1u, - blockHeight = 1u, - accountType = AccountType.NATIVE_SEGWIT, - ) + ), ) runCurrent() assertEquals(0, received.size) // New inbound tx after the baseline emits once. watcherEvents.emit( - "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), + "hardware-wallet|nativeSegwit" to transactionsChanged( + total = 150uL, activities = listOf( watcherActivity(amount = 100uL), watcherActivity(amount = 50uL, txid = "t2"), ), - transactionDetails = emptyList(), - txCount = 2u, - blockHeight = 2u, - accountType = AccountType.NATIVE_SEGWIT, - ) + ), ) runCurrent() assertEquals(listOf("t1", "t2"), sut.activities.value.map { (it as Activity.Onchain).v1.txId }) @@ -700,17 +716,13 @@ class HwWalletRepoTest : BaseUnitTest() { // Re-delivering the same set (e.g. confirmation update) must not emit again. watcherEvents.emit( - "hardware-wallet|nativeSegwit" to WatcherEvent.TransactionsChanged( - balance = walletBalance(total = 150uL), + "hardware-wallet|nativeSegwit" to transactionsChanged( + total = 150uL, activities = listOf( watcherActivity(amount = 100uL), watcherActivity(amount = 50uL, txid = "t2"), ), - transactionDetails = emptyList(), - txCount = 2u, - blockHeight = 3u, - accountType = AccountType.NATIVE_SEGWIT, - ) + ), ) runCurrent() assertEquals(1, received.size) @@ -783,6 +795,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) runCurrent() @@ -793,6 +806,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.TAPROOT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) runCurrent() @@ -805,6 +819,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) runCurrent() @@ -816,6 +831,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 2u, accountType = AccountType.TAPROOT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) runCurrent() @@ -840,6 +856,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) watcherEvents.emit( @@ -852,6 +869,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 2u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -888,6 +906,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 1u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -1337,6 +1356,88 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(40uL, account.balanceSats) } + @Test + fun `receive address uses synchronized watcher state without account scan`() = test { + wheneverStartWatcher().thenReturn(Result.success(Unit)) + val sut = createRepo() + runCurrent() + + watcherEvents.emit( + "$HARDWARE_WALLET_ID|nativeSegwit" to transactionsChanged(total = 0uL), + ) + runCurrent() + + val address = sut.getReceiveAddress(HARDWARE_WALLET_ID).getOrThrow() + + assertEquals(WATCHER_RECEIVE_ADDRESS.address, address.address) + assertEquals(WATCHER_RECEIVE_ADDRESS.path, address.path) + assertEquals(HwFundingAddressType.NATIVE_SEGWIT, address.addressType) + verify(trezorRepo, never()).getAccountInfo(any(), any(), anyOrNull()) + } + + @Test + fun `receive address falls back to the stored account xpub`() = test { + wheneverStartWatcher().thenReturn(Result.success(Unit)) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever { trezorRepo.getAccountInfo(eq("zpubNS"), any(), eq(AccountType.NATIVE_SEGWIT)) } + .thenReturn( + Result.success( + AccountInfoResult( + account = ComposeAccount( + path = "m/84'/1'/0'", + addresses = AccountAddresses( + used = emptyList(), + unused = listOf(WATCHER_RECEIVE_ADDRESS), + change = emptyList(), + ), + utxo = emptyList(), + ), + balance = 0uL, + utxoCount = 0u, + accountType = AccountType.NATIVE_SEGWIT, + blockHeight = 1u, + ) + ) + ) + val sut = createRepo() + runCurrent() + + val address = sut.getReceiveAddress(HARDWARE_WALLET_ID).getOrThrow() + + assertEquals(WATCHER_RECEIVE_ADDRESS.address, address.address) + assertEquals(WATCHER_RECEIVE_ADDRESS.path, address.path) + assertEquals(HwFundingAddressType.NATIVE_SEGWIT, address.addressType) + } + + @Test + fun `receive address verification rejects a device mismatch`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HARDWARE_WALLET_ID), + ) + whenever { trezorRepo.ensureConnected("dev1") }.thenReturn(Result.success(mock())) + whenever { trezorRepo.getAddress(any(), any(), any(), any()) }.thenReturn( + Result.success( + TrezorAddressResponse( + address = "bcrt1qdifferent", + path = WATCHER_RECEIVE_ADDRESS.path, + ) + ) + ) + val sut = createRepo() + + val result = sut.verifyReceiveAddress( + HARDWARE_WALLET_ID, + HwReceiveAddress( + address = WATCHER_RECEIVE_ADDRESS.address, + path = WATCHER_RECEIVE_ADDRESS.path, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + ), + ) + + assertTrue(result.exceptionOrNull() is HwReceiveAddressMismatchError) + } + @Test fun `keeps a stale watcher until stopping it succeeds`() = test { storeData.value = HwWalletData( @@ -1356,6 +1457,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) runCurrent() @@ -1452,6 +1554,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = 0u, blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = WATCHER_RECEIVE_ADDRESS, ) ) @@ -2007,6 +2110,7 @@ class HwWalletRepoTest : BaseUnitTest() { private fun transactionsChanged( total: ULong, activities: List = emptyList(), + receiveAddress: AddressInfo = WATCHER_RECEIVE_ADDRESS, ) = WatcherEvent.TransactionsChanged( balance = walletBalance(total), activities = activities, @@ -2014,6 +2118,7 @@ class HwWalletRepoTest : BaseUnitTest() { txCount = activities.size.toUInt(), blockHeight = 1u, accountType = AccountType.NATIVE_SEGWIT, + nextUnusedExternalAddress = receiveAddress, ) @Suppress("LongParameterList") diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index fa1bd073a7..e0f7626985 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.trezor +import com.synonym.bitkitcore.AddressInfo import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.CompletableDeferred @@ -32,6 +33,12 @@ import com.synonym.bitkitcore.Network as BitkitCoreNetwork @OptIn(ExperimentalCoroutinesApi::class) class TrezorViewModelTest : BaseUnitTest() { + private val watcherReceiveAddress = AddressInfo( + address = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8", + path = "m/84'/1'/0'/0/0", + transfers = 0u, + ) + private val trezorRepo: TrezorRepo = mock() private val trezorStateFlow = MutableStateFlow(TrezorState()) private val needsPinEntryFlow = MutableStateFlow(false) @@ -412,6 +419,7 @@ class TrezorViewModelTest : BaseUnitTest() { txCount = 3u, blockHeight = 850_000u, accountType = TrezorPreviewData.sampleTransactionHistoryResult.accountType, + nextUnusedExternalAddress = watcherReceiveAddress, ), ) advanceUntilIdle() @@ -440,6 +448,7 @@ class TrezorViewModelTest : BaseUnitTest() { txCount = 3u, blockHeight = 850_000u, accountType = TrezorPreviewData.sampleTransactionHistoryResult.accountType, + nextUnusedExternalAddress = watcherReceiveAddress, ), ) advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt new file mode 100644 index 0000000000..2fcfba9984 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt @@ -0,0 +1,113 @@ +package to.bitkit.ui.screens.wallets.receive + +import android.content.Context +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.HwFundingAddressType +import to.bitkit.models.HwReceiveAddress +import to.bitkit.models.HwWallet +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HwReceiveViewModelTest : BaseUnitTest() { + + private val context = mock() + private val hwWalletRepo = mock() + private val wallets = MutableStateFlow(persistentListOf()) + private val receiveAddress = MutableStateFlow(null) + + private lateinit var sut: HwReceiveViewModel + + @Before + fun setUp() { + whenever(hwWalletRepo.wallets).thenReturn(wallets) + whenever(hwWalletRepo.observeReceiveAddress(any(), any())).thenReturn(receiveAddress) + sut = HwReceiveViewModel(context, hwWalletRepo) + } + + @Test + fun `loads the next unused hardware address`() = test { + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS)) + + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + assertEquals(RECEIVE_ADDRESS, sut.uiState.value.address) + assertFalse(sut.uiState.value.isLoadingAddress) + assertFalse(sut.uiState.value.addressLoadFailed) + } + + @Test + fun `passphrase reconnect resumes address verification`() = test { + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS)) + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(true, false) + whenever(hwWalletRepo.reconnectWithPassphrase(WALLET_ID, PASSPHRASE)) + .thenReturn(Result.success(Unit)) + whenever(hwWalletRepo.verifyReceiveAddress(WALLET_ID, RECEIVE_ADDRESS)) + .thenReturn(Result.success(Unit)) + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + sut.verifyAddress() + advanceUntilIdle() + assertTrue(sut.uiState.value.isPassphraseRequired) + + sut.submitPassphrase(PASSPHRASE) + advanceUntilIdle() + + verify(hwWalletRepo).reconnectWithPassphrase(WALLET_ID, PASSPHRASE) + verify(hwWalletRepo).verifyReceiveAddress(WALLET_ID, RECEIVE_ADDRESS) + assertFalse(sut.uiState.value.isPassphraseRequired) + assertFalse(sut.uiState.value.isVerifyingAddress) + } + + @Test + fun `cancel clears the cached receive address`() = test { + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS)) + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + sut.cancel() + + assertEquals(HwReceiveUiState(), sut.uiState.value) + } + + @Test + fun `updates the displayed address when the watcher advances`() = test { + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS)) + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + receiveAddress.value = NEXT_RECEIVE_ADDRESS + advanceUntilIdle() + + assertEquals(NEXT_RECEIVE_ADDRESS, sut.uiState.value.address) + assertFalse(sut.uiState.value.addressLoadFailed) + } + + private companion object { + const val WALLET_ID = "trezor:wallet" + const val PASSPHRASE = "hidden wallet" + val RECEIVE_ADDRESS = HwReceiveAddress( + address = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8", + path = "m/84'/1'/0'/0/0", + addressType = HwFundingAddressType.NATIVE_SEGWIT, + ) + val NEXT_RECEIVE_ADDRESS = RECEIVE_ADDRESS.copy( + address = "bcrt1q9u8ep0ux8qll9z8vx22n3u9aetlw6ae05kgj9q", + path = "m/84'/1'/0'/0/1", + ) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt index 7905b54273..8b6e25becf 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt @@ -5,6 +5,38 @@ import kotlin.test.assertEquals class ReceiveInvoiceUtilsTest { + @Test + fun `getInvoiceForTab TREZOR returns only the hardware address`() { + val result = getInvoiceForTab( + tab = ReceiveTab.TREZOR, + bip21 = "bitcoin:software?lightning=lnbc1software", + bolt11 = "lnbc1software", + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = "bc1qsoftware", + hardwareAddress = "bc1qhardware", + ) + + assertEquals("bitcoin:bc1qhardware", result) + } + + @Test + fun `getInvoiceForTab TREZOR applies hardware invoice details`() { + val result = getInvoiceForTab( + tab = ReceiveTab.TREZOR, + bip21 = "bitcoin:software", + bolt11 = "", + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = "bc1qsoftware", + hardwareAddress = "bc1qhardware", + hardwareAmountSats = 12_345uL, + hardwareMessage = "Cold storage", + ) + + assertEquals("bitcoin:bc1qhardware?amount=0.00012345&message=Cold+storage", result) + } + private val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" private val testBolt11 = "lnbc1500n1pn2s39xpp5wyxw0e9fvvf..." private val testCjitInvoice = "lnbc2000n1pn2s39xpp5zyxw0e9fvvf..." diff --git a/changelog.d/next/1189.added.md b/changelog.d/next/1189.added.md new file mode 100644 index 0000000000..f242f0c0c9 --- /dev/null +++ b/changelog.d/next/1189.added.md @@ -0,0 +1 @@ +Added a Trezor Receive tab with fast watch-only addresses and on-device verification. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f25a4babeb..c8649c2f70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.10" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc46" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 290f816246..7d4e5ae584 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -61,8 +61,8 @@ 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. Run the send journey while the paired native-segwit account is -funded. The `passphrase-*` journeys run as a block after +Remove step forgets the device. Run the send and receive journeys while the paired native-segwit +account is funded. 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. @@ -80,6 +80,7 @@ the other three rely on, and `passphrase-settings-remove.xml` removes it again. | `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 | | `send-onchain.xml` | Normal Send flow funded by Trezor: source selection, guarded preparation, device signing, broadcast, success, and activity | +| `receive-onchain.xml` | Trezor Receive tab: current address and QR display plus exact on-device address verification | | `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 | @@ -103,9 +104,10 @@ Passphrase testTags: `HardwareWalletPairedPassphrase`, `HardwareWalletPassphrase sign screen `HwTransferPassphraseSheet`, `HwTransferPassphraseInput`, `HwTransferPassphraseCancel`, `HwTransferPassphraseContinue`. -Send testTags: `Send`, `RecipientManual`, `RecipientInput`, `AddressContinue`, +Send and receive testTags: `Send`, `RecipientManual`, `RecipientInput`, `AddressContinue`, `send_amount_screen`, `AssetButton-switch`, `ContinueAmount`, `SendConfirmAssetButton`, -`HardwareSendAmount`, `HardwareSendAddress`, `HardwareSendOpenTrezorConnect`, and `SendSuccess`. +`HardwareSendAmount`, `HardwareSendAddress`, `HardwareSendOpenTrezorConnect`, `SendSuccess`, +`Receive`, `ReceiveScreen`, `QRCode`, `ReceiveHardwareAddress`, and `HardwareVerifyAddress`. 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. diff --git a/journeys/hardware-wallet/receive-onchain.xml b/journeys/hardware-wallet/receive-onchain.xml new file mode 100644 index 0000000000..908eeace93 --- /dev/null +++ b/journeys/hardware-wallet/receive-onchain.xml @@ -0,0 +1,34 @@ + + + Opens the standard Receive flow for a paired Trezor, verifies that Bitkit shows its current + native-segwit receive address and QR code, and confirms the same address on the device. Requires + exactly one paired Bridge-emulator wallet so the global Receive sheet can select it. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the Receive button (testTag "Receive") and verify the Receive sheet opens (testTag + "ReceiveScreen") + + + Tap the "Trezor" receive tab and wait for its address to load + + + Verify a QR code is shown (testTag "QRCode") + + + Tap "Show Details" (testTag "ShowDetails"), verify a complete regtest address beginning with + "bcrt1" is shown (testTag "ReceiveHardwareAddress"), then tap "Verify on Device" (testTag + "HardwareVerifyAddress") and verify the button shows a loading state while the request is active + + + Read the complete address displayed by the Bridge emulator and verify it exactly matches the + address shown by Bitkit (testTag "ReceiveHardwareAddress"), then approve it on the emulator + + + Verify the request completes without an error and the same address remains visible in Bitkit + + + From 85d653c500b46d0edad67c995132fc6f0b4fff58 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 11:20:30 -0500 Subject: [PATCH 2/4] fix: polish trezor receive actions --- .../wallets/receive/ReceiveInvoiceUtils.kt | 2 +- .../wallets/receive/ReceiveQrScreen.kt | 60 +++++++++---------- .../receive/ReceiveInvoiceUtilsTest.kt | 16 +++++ 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index c6b3673daf..9a098c1f2d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -44,7 +44,7 @@ fun getInvoiceForTab( ReceiveTab.TREZOR -> hardwareAddress.takeIf(String::isNotBlank)?.let { address -> Bip21Utils.buildBip21Url( bitcoinAddress = address, - amountSats = hardwareAmountSats, + amountSats = hardwareAmountSats?.takeUnless { it == 0uL }, message = hardwareMessage, ) }.orEmpty() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index a9c5fd26f0..6f66ae98c7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -68,6 +68,7 @@ import to.bitkit.ui.components.Display import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.QrCodeImage +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.Tooltip import to.bitkit.ui.components.VerticalSpacer @@ -299,8 +300,6 @@ fun ReceiveQrScreen( onClickEditInvoice = onClickEditInvoice, onClickHardwareEditInvoice = onClickHardwareEditInvoice, hardwareAddress = hardwareReceiveState.address?.address, - isVerifyingHardwareAddress = hardwareReceiveState.isVerifyingAddress, - onVerifyHardwareAddress = onVerifyHardwareAddress, modifier = Modifier.weight(1f) ) } @@ -371,22 +370,35 @@ fun ReceiveQrScreen( .testTag("ShowDetails") ) - BottomButtonVariant.SHOW_QR -> PrimaryButton( - text = stringResource(R.string.wallet__receive_show_qr), - icon = { - Icon( - painter = painterResource(R.drawable.ic_qr_purple), - tint = Colors.White, - contentDescription = null, - modifier = Modifier.size(16.dp) + BottomButtonVariant.SHOW_QR -> Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (selectedTab == ReceiveTab.TREZOR) { + SecondaryButton( + text = stringResource(R.string.hardware__verify_address), + enabled = hardwareReceiveState.address != null, + isLoading = hardwareReceiveState.isVerifyingAddress, + onClick = onVerifyHardwareAddress, + modifier = Modifier.testTag("HardwareVerifyAddress") ) - }, - onClick = { showDetails = false }, - fullWidth = true, - modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("QRCode") - ) + } + + PrimaryButton( + text = stringResource(R.string.wallet__receive_show_qr), + icon = { + Icon( + painter = painterResource(R.drawable.ic_qr_purple), + tint = Colors.White, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { showDetails = false }, + fullWidth = true, + modifier = Modifier.testTag("QRCode") + ) + } BottomButtonVariant.SHOW_DETAILS -> TertiaryButton( text = stringResource(R.string.wallet__receive_show_details), @@ -558,8 +570,6 @@ private fun ReceiveDetailsView( onClickEditInvoice: () -> Unit, onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, hardwareAddress: String? = null, - isVerifyingHardwareAddress: Boolean = false, - onVerifyHardwareAddress: () -> Unit = {}, modifier: Modifier = Modifier, ) { Card( @@ -644,18 +654,6 @@ private fun ReceiveDetailsView( testTag = "ReceiveHardwareAddress", ) } - VerticalSpacer(16.dp) - PrimaryButton( - text = stringResource(R.string.hardware__verify_address), - enabled = hardwareAddress != null, - isLoading = isVerifyingHardwareAddress, - onClick = onVerifyHardwareAddress, - color = Colors.Blue, - enableGradient = false, - modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("HardwareVerifyAddress") - ) } } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt index 8b6e25becf..fcea3a0d35 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt @@ -37,6 +37,22 @@ class ReceiveInvoiceUtilsTest { assertEquals("bitcoin:bc1qhardware?amount=0.00012345&message=Cold+storage", result) } + @Test + fun `getInvoiceForTab TREZOR omits a zero amount`() { + val result = getInvoiceForTab( + tab = ReceiveTab.TREZOR, + bip21 = "bitcoin:bc1qsoftware", + bolt11 = "", + cjitInvoice = null, + isNodeRunning = true, + onchainAddress = "bc1qsoftware", + hardwareAddress = "bc1qhardware", + hardwareAmountSats = 0uL, + ) + + assertEquals("bitcoin:bc1qhardware", result) + } + private val testAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" private val testBolt11 = "lnbc1500n1pn2s39xpp5wyxw0e9fvvf..." private val testCjitInvoice = "lnbc2000n1pn2s39xpp5zyxw0e9fvvf..." From b5be4ba0c0d680555bdc939b862437a5d5d07125 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 13:19:41 -0500 Subject: [PATCH 3/4] fix: harden trezor receive flow --- .../wallets/receive/EditInvoiceContentTest.kt | 55 +++++++++++++++++++ .../wallets/receive/EditInvoiceScreen.kt | 26 +++++---- .../wallets/receive/HwReceiveViewModel.kt | 9 +++ .../wallets/receive/ReceiveQrScreen.kt | 5 +- .../wallets/receive/HwReceiveViewModelTest.kt | 26 +++++++++ 5 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceContentTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceContentTest.kt new file mode 100644 index 0000000000..9776f1427c --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceContentTest.kt @@ -0,0 +1,55 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.previewAmountInputViewModel +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@ComposeUi +class EditInvoiceContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun hardwareInvoiceContinuesOnchainWithoutTags() { + var updatedAmount: ULong? = null + var regularContinueCalled = false + + composeTestRule.setContent { + AppThemeSurface { + EditInvoiceContent( + amountInputViewModel = previewAmountInputViewModel(sats = 12_345), + noteText = "Hardware payment", + isSoftKeyboardVisible = false, + onchainOnly = true, + keyboardVisible = false, + tags = persistentListOf("Hardware"), + onBack = {}, + onContinueKeyboard = {}, + onClickBalance = {}, + onContinueGeneral = { regularContinueCalled = true }, + onContinueOnchain = { updatedAmount = it }, + onClickAddTag = {}, + onTextChanged = {}, + onClickTag = {}, + ) + } + } + + composeTestRule.onNodeWithTag("TagsAdd").assertDoesNotExist() + composeTestRule.onNodeWithTag("Tag-Hardware").assertDoesNotExist() + composeTestRule.onNodeWithTag("ShowQrReceive").performClick() + + assertEquals(12_345uL, updatedAmount) + assertFalse(regularContinueCalled) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 48010f149e..5981ce6bdf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -148,13 +148,10 @@ fun EditInvoiceScreen( } }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { - if (onchainOnly) { - updateOnchainInvoice(amountInputUiState.sats.toULong()) - onBack() - } else { - editInvoiceVM.onClickContinue() - } + onContinueGeneral = editInvoiceVM::onClickContinue, + onContinueOnchain = { amountSats -> + updateOnchainInvoice(amountSats) + onBack() }, isLoading = isLoading, onClickAddTag = onClickAddTag, @@ -164,7 +161,7 @@ fun EditInvoiceScreen( onClickPaymentRequest = { onClickPaymentRequest(amountInputUiState.sats.toULong(), walletUiState.bip21Description) }, - allowsTags = !onchainOnly, + onchainOnly = onchainOnly, ) } @@ -174,13 +171,14 @@ fun EditInvoiceContent( amountInputViewModel: AmountInputViewModel, noteText: String, isSoftKeyboardVisible: Boolean, - allowsTags: Boolean = true, + onchainOnly: Boolean = false, keyboardVisible: Boolean, tags: ImmutableList, onBack: () -> Unit, onContinueKeyboard: () -> Unit, onClickBalance: () -> Unit, onContinueGeneral: () -> Unit, + onContinueOnchain: (ULong) -> Unit = {}, onClickAddTag: () -> Unit, onTextChanged: (String) -> Unit, onClickTag: (String) -> Unit, @@ -326,7 +324,7 @@ fun EditInvoiceContent( ) VerticalSpacer(16.dp) - if (allowsTags) { + if (!onchainOnly) { Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) VerticalSpacer(8.dp) @@ -375,7 +373,13 @@ fun EditInvoiceContent( PrimaryButton( text = stringResource(R.string.wallet__receive_show_qr), - onClick = onContinueGeneral, + onClick = { + if (onchainOnly) { + onContinueOnchain(amountInputViewModel.uiState.value.sats.toULong()) + } else { + onContinueGeneral() + } + }, isLoading = isLoading, modifier = Modifier.testTag("ShowQrReceive") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt index 074ab7ef6b..6ac6dd1262 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModel.kt @@ -56,6 +56,9 @@ class HwReceiveViewModel @Inject constructor( addressUpdatesJob = viewModelScope.launch { hwWalletRepo.observeReceiveAddress(walletId).collect { address -> if (address != null && _uiState.value.walletId == walletId) { + if (_uiState.value.address != null && _uiState.value.address != address) { + invalidateVerification() + } _uiState.update { it.copy(address = address, isLoadingAddress = false, addressLoadFailed = false) } @@ -161,6 +164,12 @@ class HwReceiveViewModel @Inject constructor( _uiState.update { HwReceiveUiState() } } + private fun invalidateVerification() { + verifyJob?.cancel() + passphraseJob?.cancel() + _uiState.update { it.copy(isPassphraseRequired = false) } + } + private suspend fun handleVerifyFailure(error: Throwable) { when { error.isTrezorUserCancellation() -> Unit diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 6f66ae98c7..6cf92e3294 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -300,6 +300,7 @@ fun ReceiveQrScreen( onClickEditInvoice = onClickEditInvoice, onClickHardwareEditInvoice = onClickHardwareEditInvoice, hardwareAddress = hardwareReceiveState.address?.address, + hardwareInvoice = invoicesByTab[ReceiveTab.TREZOR].orEmpty(), modifier = Modifier.weight(1f) ) } @@ -570,6 +571,7 @@ private fun ReceiveDetailsView( onClickEditInvoice: () -> Unit, onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, hardwareAddress: String? = null, + hardwareInvoice: String = "", modifier: Modifier = Modifier, ) { Card( @@ -647,7 +649,8 @@ private fun ReceiveDetailsView( hardwareAddress?.let { address -> CopyAddressCard( title = stringResource(R.string.wallet__receive_bitcoin_invoice), - address = address, + address = hardwareInvoice.ifBlank { address }, + body = address, type = CopyAddressType.ONCHAIN, onClickEditInvoice = onClickHardwareEditInvoice, accentColor = Colors.Blue, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt index 2fcfba9984..1cd227d329 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt @@ -2,12 +2,14 @@ package to.bitkit.ui.screens.wallets.receive import android.content.Context import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -97,6 +99,30 @@ class HwReceiveViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.addressLoadFailed) } + @Test + fun `watcher address change cancels active verification`() = test { + val verificationStarted = CompletableDeferred() + val verificationResult = CompletableDeferred>() + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS)) + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false) + whenever(hwWalletRepo.verifyReceiveAddress(WALLET_ID, RECEIVE_ADDRESS)).doSuspendableAnswer { + verificationStarted.complete(Unit) + verificationResult.await() + } + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + sut.verifyAddress() + verificationStarted.await() + assertTrue(sut.uiState.value.isVerifyingAddress) + + receiveAddress.value = NEXT_RECEIVE_ADDRESS + advanceUntilIdle() + + assertEquals(NEXT_RECEIVE_ADDRESS, sut.uiState.value.address) + assertFalse(sut.uiState.value.isVerifyingAddress) + } + private companion object { const val WALLET_ID = "trezor:wallet" const val PASSPHRASE = "hidden wallet" From a30d24c301f35c2359f321296e0cf617b7c45f66 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 28 Aug 2026 16:14:58 -0500 Subject: [PATCH 4/4] fix: address trezor receive review --- .../hardware/HwPassphrasePromptSheet.kt | 16 ++++---- .../screens/wallets/receive/ReceiveSheet.kt | 7 +++- app/src/main/res/values/strings.xml | 1 + .../bitkit/repositories/HwWalletRepoTest.kt | 38 +++++++++++++++++++ .../wallets/receive/HwReceiveViewModelTest.kt | 23 +++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt index f175f6c62c..45b6fde42b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HwPassphrasePromptSheet.kt @@ -55,17 +55,14 @@ 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. - */ +/** Asks for the passphrase needed to reopen a hidden hardware wallet session. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun HwPassphrasePromptSheet( isVerifying: Boolean, onSubmit: (String) -> Unit, onDismiss: () -> Unit, + bodyText: String = stringResource(R.string.hardware__passphrase_sign_text), ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -92,6 +89,7 @@ fun HwPassphrasePromptSheet( ) { Content( isVerifying = isVerifying, + bodyText = bodyText, onSubmit = { dismissKeyboard() onSubmit(it) @@ -105,6 +103,7 @@ fun HwPassphrasePromptSheet( @Composable private fun Content( isVerifying: Boolean, + bodyText: String, modifier: Modifier = Modifier, onSubmit: (String) -> Unit = {}, onCancel: () -> Unit = {}, @@ -135,7 +134,7 @@ private fun Content( ) { 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) + BodyM(bodyText, color = Colors.White64) VerticalSpacer(32.dp) TextInput( value = passphrase, @@ -200,6 +199,9 @@ private fun Content( @Composable private fun Preview() { AppThemeSurface { - Content(isVerifying = false) + Content( + isVerifying = false, + bodyText = stringResource(R.string.hardware__passphrase_sign_text), + ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 58d284c2d5..0df971b089 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -140,7 +140,11 @@ fun ReceiveSheet( isEditingHardwareInvoice.value = true navController.navigateTo(ReceiveRoute.EditInvoice) }, - initialTab = if (hardwareWalletId != null) ReceiveTab.TREZOR else null, + initialTab = if (hardwareWalletId != null || isEditingHardwareInvoice.value) { + ReceiveTab.TREZOR + } else { + null + }, hardwareWalletId = selectedHardwareWalletId, hardwareReceiveState = hwReceiveState, onLoadHardwareAddress = hwReceiveViewModel::loadAddress, @@ -328,6 +332,7 @@ fun ReceiveSheet( isVerifying = hwReceiveState.isVerifyingPassphrase, onSubmit = hwReceiveViewModel::submitPassphrase, onDismiss = hwReceiveViewModel::dismissPassphrase, + bodyText = stringResource(R.string.hardware__passphrase_verify_address_text), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d8df22574..30c8a6ee5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,7 @@ 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 + Enter the passphrase of this wallet so your hardware device can display the receive address for verification. Could not load the hardware wallet address. Remove %1$s Keep name and tags in backup diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 1e3b8f38c1..5e31cda7f8 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -1438,6 +1438,44 @@ class HwWalletRepoTest : BaseUnitTest() { assertTrue(result.exceptionOrNull() is HwReceiveAddressMismatchError) } + @Test + fun `receive address verification reconnects after a session failure`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + trezorState.value = TrezorState( + connected = ConnectedTrezorDevice(id = "dev1", features = mock(), walletId = HARDWARE_WALLET_ID), + ) + whenever { trezorRepo.ensureConnected("dev1") }.thenReturn(Result.success(mock())) + whenever { trezorRepo.getAddress(any(), any(), any(), any()) }.thenReturn( + Result.failure(TrezorException.ProtocolException("THP decryption error: aead::Error")), + Result.success( + TrezorAddressResponse( + address = WATCHER_RECEIVE_ADDRESS.address, + path = WATCHER_RECEIVE_ADDRESS.path, + ) + ), + ) + whenever { trezorRepo.disconnectStaleSession("dev1") }.thenReturn(Result.success(Unit)) + val sut = createRepo() + + val result = sut.verifyReceiveAddress( + HARDWARE_WALLET_ID, + HwReceiveAddress( + address = WATCHER_RECEIVE_ADDRESS.address, + path = WATCHER_RECEIVE_ADDRESS.path, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + ), + ) + + assertTrue(result.isSuccess) + inOrder(trezorRepo) { + verify(trezorRepo).ensureConnected("dev1") + verify(trezorRepo).getAddress(any(), any(), any(), any()) + verify(trezorRepo).disconnectStaleSession("dev1") + verify(trezorRepo).ensureConnected("dev1") + verify(trezorRepo).getAddress(any(), any(), any(), any()) + } + } + @Test fun `keeps a stale watcher until stopping it succeeds`() = test { storeData.value = HwWalletData( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt index 1cd227d329..86b24feb38 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/HwReceiveViewModelTest.kt @@ -11,6 +11,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.HwFundingAddressType @@ -18,6 +19,7 @@ import to.bitkit.models.HwReceiveAddress import to.bitkit.models.HwWallet import to.bitkit.repositories.HwWalletRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -51,6 +53,27 @@ class HwReceiveViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.addressLoadFailed) } + @Test + fun `retry loads the hardware address after a failure`() = test { + whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn( + Result.failure(AppError("address unavailable")), + Result.success(RECEIVE_ADDRESS), + ) + + sut.loadAddress(WALLET_ID) + advanceUntilIdle() + + assertTrue(sut.uiState.value.addressLoadFailed) + + sut.retryAddress() + advanceUntilIdle() + + assertEquals(RECEIVE_ADDRESS, sut.uiState.value.address) + assertFalse(sut.uiState.value.isLoadingAddress) + assertFalse(sut.uiState.value.addressLoadFailed) + verify(hwWalletRepo, times(2)).getReceiveAddress(WALLET_ID) + } + @Test fun `passphrase reconnect resumes address verification`() = test { whenever(hwWalletRepo.getReceiveAddress(WALLET_ID)).thenReturn(Result.success(RECEIVE_ADDRESS))