From e2256f4bcabf917e5f785cb84de30b045bfc4dc1 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 12:31:28 +0200 Subject: [PATCH 01/10] fix: reset trezor pairing on retry --- .../to/bitkit/repositories/HwWalletRepo.kt | 3 + .../java/to/bitkit/repositories/TrezorRepo.kt | 11 +-- .../java/to/bitkit/services/TrezorDebugLog.kt | 12 +++- .../to/bitkit/services/TrezorTransport.kt | 70 ++++++++++++++++--- .../ui/screens/trezor/TrezorViewModel.kt | 1 - .../to/bitkit/ui/settings/SettingsScreen.kt | 6 +- .../ui/sheets/hardware/HardwareSheet.kt | 10 ++- .../ui/sheets/hardware/HwConnectViewModel.kt | 8 +-- .../ui/sheets/hardware/HwPairPinSheet.kt | 7 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 26 +++---- .../to/bitkit/repositories/TrezorRepoTest.kt | 27 +++++++ .../to/bitkit/services/TrezorDebugLogTest.kt | 25 +++++++ .../to/bitkit/services/TrezorTransportTest.kt | 54 ++++++++++++++ .../sheets/hardware/HwConnectViewModelTest.kt | 19 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 10 ++- changelog.d/next/1030.fixed.md | 1 + 16 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt create mode 100644 changelog.d/next/1030.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 1130dd23c8..cbe3166289 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -128,6 +128,9 @@ class HwWalletRepo @Inject constructor( /** Pairing-code request raised by the device during connect; the UI shows the Pair Device sheet. */ val needsPairingCode = trezorRepo.needsPairingCode + /** Identity of the active request, incremented for each pairing-code callback. */ + val pairingCodeRequestId = trezorRepo.pairingCodeRequestId + fun submitPairingCode(code: String) = trezorRepo.submitPairingCode(code) fun cancelPairingCode() = trezorRepo.cancelPairingCode() diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 56a29c92c3..caa954ab53 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -38,7 +38,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -52,6 +51,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -62,6 +62,7 @@ import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.HwWalletId import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath @@ -77,7 +78,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.TrezorErrorPresenter import java.io.File -import to.bitkit.models.HwWalletId import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -152,6 +152,9 @@ class TrezorRepo @Inject constructor( */ val needsPairingCode = trezorTransport.needsPairingCode + /** Identity of the active pairing-code request; changes for every transport callback. */ + val pairingCodeRequestId = trezorTransport.pairingCodeRequestId + /** * Submit the pairing code entered by the user. */ @@ -876,8 +879,7 @@ class TrezorRepo @Inject constructor( gapLimit = gapLimit, ) trezorService.startWatcher(params, eventBridge) - TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId' for '${extendedKey.take(12)}...'") - Logger.info("Started watcher '$watcherId'", context = TAG) + TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId'") }.onFailure { Logger.error("Start watcher failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } @@ -889,7 +891,6 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.stopWatcher(watcherId) TrezorDebugLog.log(WATCHER_TAG, "Stopped watcher '$watcherId'") - Logger.info("Stopped watcher '$watcherId'", context = TAG) }.onFailure { Logger.error("Stop watcher failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt index 48ffb11154..e589b5e33c 100644 --- a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -7,20 +7,27 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import to.bitkit.utils.Logger import java.text.SimpleDateFormat import java.util.Date import java.util.Locale object TrezorDebugLog { + private const val TAG = "TrezorDebugLog" private const val MAX_LINES = 300 + private val secretValuePattern = Regex( + """(?i)\b(mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|extended[ _-]?key|psbt|""" + + """raw[ _-]?tx|serialized[ _-]?tx)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)""", + ) private val _lines = MutableStateFlow>(persistentListOf()) val lines: StateFlow> = _lines.asStateFlow() private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) fun log(tag: String, msg: String) { - val ts = fmt.format(Date()) - val line = "$ts [$tag] $msg" + val sanitizedMessage = secretValuePattern.replace(msg) { "${it.groupValues[1]}=" } + val ts = synchronized(fmt) { fmt.format(Date()) } + val line = "$ts [$tag] $sanitizedMessage" _lines.update { current -> val updated = current + line if (updated.size > MAX_LINES) { @@ -29,6 +36,7 @@ object TrezorDebugLog { updated.toImmutableList() } } + Logger.debug("Recorded Trezor diagnostic '$tag': '$sanitizedMessage'", context = TAG) } fun clear() { diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 6a7d2672eb..1da90839db 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -180,7 +180,15 @@ class TrezorTransport @Inject constructor( private val connectionStateReceiver = ConnectionStateReceiver( onBluetoothOff = { bleConnections.keys.toList().forEach { path -> - bleConnections[path]?.isConnected = false + bleConnections[path]?.let { + it.isConnected = false + it.writeStatus = BluetoothGatt.GATT_FAILURE + releasePendingBleOperations( + connectionLatch = it.connectionLatch, + writeLatch = it.writeLatch, + disconnectLatch = it.disconnectLatch, + ) + } emitExternalDisconnect(path) } }, @@ -382,8 +390,6 @@ class TrezorTransport @Inject constructor( override fun getPairingCode(): String { TrezorDebugLog.log("PAIR", ">>> PAIRING CODE REQUESTED - Device requires re-pairing! <<<") - Logger.info("Requested pairing code from user", context = TAG) - Logger.info("Asked user to read the 6-digit code from Trezor screen", context = TAG) val latch = CountDownLatch(1) @@ -391,6 +397,8 @@ class TrezorTransport @Inject constructor( pairingCodeResult = null pairingCodeRequest = PairingCodeRequest(isRequested = true, latch = latch) _needsPairingCode.update { true } + val requestId = nextPairingCodeRequestId++ + _pairingCodeRequestId.update { requestId } } val result = try { @@ -459,6 +467,7 @@ class TrezorTransport @Inject constructor( pairingCodeRequest = PairingCodeRequest() pairingCodeResult = null _needsPairingCode.update { false } + _pairingCodeRequestId.update { null } } /** @@ -468,6 +477,10 @@ class TrezorTransport @Inject constructor( private val _needsPairingCode = MutableStateFlow(false) val needsPairingCode: StateFlow = _needsPairingCode + private var nextPairingCodeRequestId = 1L + private val _pairingCodeRequestId = MutableStateFlow(null) + val pairingCodeRequestId: StateFlow = _pairingCodeRequestId + /** * Submit a pairing code from the UI. * This unblocks the getPairingCode() call waiting on the Rust side. @@ -706,7 +719,11 @@ class TrezorTransport @Inject constructor( val usbInterface = device.getInterface(0) if (!connection.claimInterface(usbInterface, true)) { connection.close() - return TrezorTransportWriteResult(success = false, error = "Failed to claim interface", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to claim interface", + errorCode = null + ) } val endpoints = findUsbEndpoints(usbInterface) @@ -785,7 +802,12 @@ class TrezorTransport @Inject constructor( TrezorTransportReadResult(success = true, data = buffer.copyOf(bytesRead), error = "", errorCode = null) } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Unknown error", + errorCode = null + ) } } @@ -875,14 +897,22 @@ class TrezorTransport @Inject constructor( if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { - return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Failed to initiate bonding", + errorCode = null + ) } var bondAttempts = 0 while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { - return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected", errorCode = null) + return TrezorTransportWriteResult( + success = false, + error = "Bonding failed or rejected", + errorCode = null + ) } } if (device.bondState != BluetoothDevice.BOND_BONDED) { @@ -976,8 +1006,11 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportWriteResult(success = true, error = "", errorCode = null) connection.readQueue.clear() - connection.writeLatch?.countDown() - connection.connectionLatch?.countDown() + releasePendingBleOperations( + connectionLatch = connection.connectionLatch, + writeLatch = connection.writeLatch, + disconnectLatch = connection.disconnectLatch, + ) Logger.info("Closed BLE device session '$path'", context = TAG) return TrezorTransportWriteResult(success = true, error = "", errorCode = null) } @@ -991,7 +1024,7 @@ class TrezorTransport @Inject constructor( userInitiatedCloseSet.add(path) return try { val disconnectLatch = CountDownLatch(1) - bleConnections[path] = connection.copy(disconnectLatch = disconnectLatch) + connection.disconnectLatch = disconnectLatch connection.gatt.disconnect() @@ -1037,7 +1070,12 @@ class TrezorTransport @Inject constructor( TrezorTransportReadResult(success = true, data = data, error = "", errorCode = null) } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) - TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed", errorCode = null) + TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = e.message ?: "Read failed", + errorCode = null + ) } } @@ -1361,3 +1399,13 @@ class TrezorTransport @Inject constructor( bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } + +internal fun releasePendingBleOperations( + connectionLatch: CountDownLatch?, + writeLatch: CountDownLatch?, + disconnectLatch: CountDownLatch?, +) { + connectionLatch?.countDown() + writeLatch?.countDown() + disconnectLatch?.countDown() +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 6c9f0f831e..624c316a75 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -498,7 +498,6 @@ class TrezorViewModel @Inject constructor( val feeRate = state.sendFeeRate.toFloatOrNull() ?: return@launch _uiState.update { it.copy(send = it.send.copy(isComposing = true)) } TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") - TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") TrezorDebugLog.log("COMPOSE", "feeRate=$feeRate sat/vB, network=${state.selectedNetwork}") TrezorDebugLog.log("COMPOSE", "coinSelection=${state.coinSelection}") diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index 195194b578..11e708c96d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -373,7 +373,11 @@ private fun GeneralTabContent( SettingsButtonRow( title = stringResource(R.string.settings__hardware_wallets__nav_title), icon = { SettingsIcon(R.drawable.ic_device_mobile_speaker) }, - value = SettingsButtonValue.StringValue(state.hardwareWalletCount.toString()), + value = if (state.hardwareWalletCount > 0) { + SettingsButtonValue.StringValue(state.hardwareWalletCount.toString()) + } else { + SettingsButtonValue.None + }, onClick = { onEvent(SettingsEvent.HardwareWalletsClick) }, modifier = Modifier.testTag("HardwareWalletsSettings") ) 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 633becf2ab..2aed73cc94 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 @@ -175,8 +175,10 @@ fun HardwareSheet( onFinish = viewModel::onFinishClick, ) } - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() HwPairCodeSheet( + requestId = route.requestId, onSubmit = appViewModel::submitPairingCode, onCancel = appViewModel::cancelPairingCode, ) @@ -220,7 +222,9 @@ private fun ConnectEffectHandler( deviceModel = effect.deviceModel, ), ) - HwConnectEffect.NavigateToPairCode -> navController.navigateTo(HardwareRoute.PairCode) + is HwConnectEffect.NavigateToPairCode -> navController.navigateTo( + HardwareRoute.PairCode(requestId = effect.requestId), + ) HwConnectEffect.NavigateToPaired -> navController.navigateTo(HardwareRoute.Paired) HwConnectEffect.Dismiss -> appViewModel.hideSheet() HwConnectEffect.Finish -> { @@ -249,5 +253,5 @@ sealed interface HardwareRoute { data object Paired : HardwareRoute @Serializable - data object PairCode : HardwareRoute + data class PairCode(val requestId: Long) : HardwareRoute } 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 426a487bda..392abc1a29 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 @@ -17,9 +17,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.HwWalletRepo.Companion.DEVICE_LABEL_MAX_LENGTH -import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.repositories.resolveHwWalletName import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject @@ -230,8 +230,8 @@ class HwConnectViewModel @Inject constructor( private fun observePairingCode() { viewModelScope.launch { - hwWalletRepo.needsPairingCode.collect { needsCode -> - if (needsCode) setEffect(HwConnectEffect.NavigateToPairCode) + hwWalletRepo.pairingCodeRequestId.collect { requestId -> + if (requestId != null) setEffect(HwConnectEffect.NavigateToPairCode(requestId)) } } } @@ -272,7 +272,7 @@ data class HwConnectUiState( sealed interface HwConnectEffect { data object NavigateToSearching : HwConnectEffect data class NavigateToFound(val deviceId: String, val deviceModel: String) : HwConnectEffect - data object NavigateToPairCode : HwConnectEffect + data class NavigateToPairCode(val requestId: Long) : HwConnectEffect data object NavigateToPaired : HwConnectEffect data object Dismiss : HwConnectEffect data object Finish : HwConnectEffect diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt index 1df72ef573..105a429cf2 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HwPairPinSheet.kt @@ -50,16 +50,17 @@ private val PAIRING_CELL_SPACING = 8.dp @Composable fun HwPairCodeSheet( + requestId: Long, onSubmit: (String) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { - var code by remember { mutableStateOf("") } - var submitted by remember { mutableStateOf(false) } + var code by remember(requestId) { mutableStateOf("") } + var submitted by remember(requestId) { mutableStateOf(false) } // Dismissing the sheet without submitting (e.g. swipe down) cancels the pending // pairing request so the connect attempt does not hang until its timeout. - DisposableEffect(Unit) { + DisposableEffect(requestId) { onDispose { if (!submitted) onCancel() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dd7217c838..6e43fbb424 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -259,7 +259,7 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() - private var isPairingCodeSheetQueued = false + private var queuedPairingCodeRequestId: Long? = null private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() @@ -341,13 +341,13 @@ class AppViewModel @Inject constructor( } } viewModelScope.launch { - hwWalletRepo.needsPairingCode.collect { needsCode -> - if (needsCode) { - showPairingCodeSheet() + hwWalletRepo.pairingCodeRequestId.collect { requestId -> + if (requestId != null) { + showPairingCodeSheet(requestId) } else { - isPairingCodeSheetQueued = false + queuedPairingCodeRequestId = null _currentSheet.update { sheet -> - if (sheet is Sheet.Hardware && sheet.route == HardwareRoute.PairCode) null else sheet + if (sheet is Sheet.Hardware && sheet.route is HardwareRoute.PairCode) null else sheet } } } @@ -3103,9 +3103,9 @@ class AppViewModel @Inject constructor( * any screen via silent reconnects, so the sheet is shown app-wide. High-priority * sheets are not interrupted: the pairing sheet waits until the active sheet closes. */ - private fun showPairingCodeSheet() { + private fun showPairingCodeSheet(requestId: Long) { if (isHighPrioritySheet(_currentSheet.value)) { - isPairingCodeSheetQueued = true + queuedPairingCodeRequestId = requestId return } @@ -3113,18 +3113,18 @@ class AppViewModel @Inject constructor( // inline within its own NavHost; replacing it here would tear down that back stack. if (_currentSheet.value is Sheet.Hardware) return - isPairingCodeSheetQueued = false - showSheet(Sheet.Hardware(route = HardwareRoute.PairCode)) + queuedPairingCodeRequestId = null + showSheet(Sheet.Hardware(route = HardwareRoute.PairCode(requestId))) } private fun showQueuedPairingCodeSheet() { - if (!isPairingCodeSheetQueued) return + val requestId = queuedPairingCodeRequestId ?: return if (!hwWalletRepo.needsPairingCode.value) { - isPairingCodeSheetQueued = false + queuedPairingCodeRequestId = null return } - showPairingCodeSheet() + showPairingCodeSheet(requestId) } private fun isHighPrioritySheet(sheet: Sheet?) = sheet is Sheet.Gift || diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 5b135450a4..a10d69c1af 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -455,6 +455,33 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).scan() } + @Test + fun `bluetooth restore reconnects only after pairing request clears`() = test { + val transportRestored = MutableSharedFlow() + val needsPairingCode = MutableStateFlow(true) + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(trezorTransport.transportRestored).thenReturn(transportRestored) + whenever(trezorTransport.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any(), eq(false))).thenReturn(features) + sut = createSut() + + transportRestored.emit(TransportType.BLUETOOTH) + advanceUntilIdle() + verify(trezorService, never()).scan() + + needsPairingCode.value = false + transportRestored.emit(TransportType.BLUETOOTH) + advanceUntilIdle() + + verify(trezorService).scan() + verify(trezorService).connect(eq(DEVICE_ID), any(), eq(false)) + } + @Test fun `onTransportRestored auto-reconnects to a known device`() = test { val features = mockFeatures() diff --git a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt new file mode 100644 index 0000000000..80fe247553 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt @@ -0,0 +1,25 @@ +package to.bitkit.services + +import org.junit.After +import org.junit.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class TrezorDebugLogTest { + @After + fun tearDown() { + TrezorDebugLog.clear() + } + + @Test + fun `sensitive diagnostic values are redacted`() { + TrezorDebugLog.log("REDACTION_TEST", "pin=1234 passphrase='hidden wallet'") + + val line = TrezorDebugLog.lines.value.last { "[REDACTION_TEST]" in it } + + assertContains(line, "pin=") + assertContains(line, "passphrase=") + assertFalse("1234" in line) + assertFalse("hidden wallet" in line) + } +} diff --git a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt index 4b79152802..8145203f9e 100644 --- a/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorTransportTest.kt @@ -12,9 +12,14 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.Collections +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue +import kotlin.test.fail class TrezorTransportTest { @@ -64,6 +69,55 @@ class TrezorTransportTest { verify(context, never()).getSystemService(Context.BLUETOOTH_SERVICE) } + @Test + fun `each pairing callback emits a distinct request id`() { + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("to.bitkit.dev") + val sut = createSut() + val results = Collections.synchronizedList(mutableListOf()) + + val firstCallback = thread { results += sut.getPairingCode() } + val firstRequestId = awaitPairingRequestId(sut) + sut.submitPairingCode("123456") + firstCallback.join() + + val secondCallback = thread { results += sut.getPairingCode() } + val secondRequestId = awaitPairingRequestId(sut, excluding = firstRequestId) + sut.submitPairingCode("654321") + secondCallback.join() + + assertNotEquals(firstRequestId, secondRequestId) + assertEquals(listOf("123456", "654321"), results) + } + + @Test + fun `bluetooth off releases every pending operation`() { + val connectionLatch = CountDownLatch(1) + val writeLatch = CountDownLatch(1) + val disconnectLatch = CountDownLatch(1) + + releasePendingBleOperations( + connectionLatch = connectionLatch, + writeLatch = writeLatch, + disconnectLatch = disconnectLatch, + ) + + assertEquals(0L, connectionLatch.count) + assertEquals(0L, writeLatch.count) + assertEquals(0L, disconnectLatch.count) + } + + private fun awaitPairingRequestId( + sut: TrezorTransport, + excluding: Long? = null, + ): Long { + repeat(100) { + sut.pairingCodeRequestId.value?.takeIf { it != excluding }?.let { return it } + Thread.sleep(10) + } + fail("Pairing code request was not emitted") + } + private fun createSut() = TrezorTransport( context = context, bridgeTransport = bridgeTransport, 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 b4bfbca67c..8c6c0f280b 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 @@ -34,7 +34,7 @@ class HwConnectViewModelTest : BaseUnitTest() { private val hwWalletRepo = mock() private val context = mock() - private val needsPairingCode = MutableStateFlow(false) + private val pairingCodeRequestId = MutableStateFlow(null) private val wallets = MutableStateFlow>(persistentListOf()) private val deviceState = MutableStateFlow(TrezorState()) @@ -42,7 +42,7 @@ class HwConnectViewModelTest : BaseUnitTest() { @Before fun setUp() { - whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(hwWalletRepo.wallets).thenReturn(wallets) whenever(hwWalletRepo.deviceState).thenReturn(deviceState) whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_ERROR) @@ -225,8 +225,19 @@ class HwConnectViewModelTest : BaseUnitTest() { @Test fun `pairing code request surfaces the inline pair code step`() = test { sut.effects.test { - needsPairingCode.value = true - assertEquals(HwConnectEffect.NavigateToPairCode, awaitItem()) + pairingCodeRequestId.value = 1L + assertEquals(HwConnectEffect.NavigateToPairCode(1L), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `repeated pairing code request emits a new inline pair code step`() = test { + sut.effects.test { + pairingCodeRequestId.value = 1L + assertEquals(HwConnectEffect.NavigateToPairCode(1L), awaitItem()) + pairingCodeRequestId.value = 2L + assertEquals(HwConnectEffect.NavigateToPairCode(2L), awaitItem()) cancelAndIgnoreRemainingEvents() } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f0ebda6e1c..001224b1f3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -132,6 +132,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val balanceState = MutableStateFlow(BalanceState()) private val hwReceivedTxs = MutableSharedFlow() private val needsPairingCode = MutableStateFlow(false) + private val pairingCodeRequestId = MutableStateFlow(null) private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) @@ -159,6 +160,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) + whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) @@ -296,11 +298,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `pairing code request shows and hides the pair device sheet`() = test { needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() - assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode), sut.currentSheet.value) + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(1L)), sut.currentSheet.value) needsPairingCode.value = false + pairingCodeRequestId.value = null advanceUntilIdle() assertNull(sut.currentSheet.value) @@ -312,6 +316,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() assertEquals(Sheet.Pin(), sut.currentSheet.value) @@ -323,12 +328,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() needsPairingCode.value = true + pairingCodeRequestId.value = 1L advanceUntilIdle() sut.hideSheet() advanceUntilIdle() - assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode), sut.currentSheet.value) + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(1L)), sut.currentSheet.value) } @Test diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1030.fixed.md new file mode 100644 index 0000000000..e35c122dcd --- /dev/null +++ b/changelog.d/next/1030.fixed.md @@ -0,0 +1 @@ +Fixed repeated Trezor pairing prompts and Bluetooth-off recovery during hardware wallet connections. From 7d6f72b645c2fbcd8d24bab1478c6dc507eed3aa Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 12:31:53 +0200 Subject: [PATCH 02/10] fix: show unlock guidance for locked trezor --- .../java/to/bitkit/ext/TrezorExceptionExt.kt | 7 +++ .../java/to/bitkit/repositories/TrezorRepo.kt | 25 ++++++-- .../to/bitkit/utils/TrezorErrorPresenter.kt | 6 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 16 ++--- .../to/bitkit/ext/TrezorExceptionExtTest.kt | 13 ++++ .../to/bitkit/repositories/TrezorRepoTest.kt | 23 +++++++ .../bitkit/utils/TrezorErrorPresenterTest.kt | 7 +++ .../viewmodels/TransferViewModelTest.kt | 63 ++++++++++++++++--- changelog.d/next/1030.fixed.md | 2 +- 9 files changed, 137 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt index 30136a4ff1..1872456632 100644 --- a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt @@ -11,3 +11,10 @@ fun Throwable.isTrezorUserCancellation(): Boolean = fun Throwable.isTrezorDeviceBusy(): Boolean = generateSequence(this) { it.cause }.any { it is TrezorException.DeviceBusy } + +fun Throwable.isTrezorLockedOrBusy(): Boolean = + isTrezorDeviceBusy() || + generateSequence(this) { it.cause }.any { + val message = it.message.orEmpty() + "Device error (code 99)" in message && "Firmware error" in message + } diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index caa954ab53..fc71c46d19 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -57,6 +57,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorLockedOrBusy import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching @@ -719,13 +720,27 @@ class TrezorRepo @Inject constructor( } suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { - awaitConnectedOrNull(deviceId)?.let { return@withContext Result.success(it) } - if (isKnownBluetoothDevice(deviceId)) { - return@withContext reconnectKnownBluetoothDevice(deviceId) + val result = awaitConnectedOrNull(deviceId)?.let { Result.success(it) } ?: run { + if (isKnownBluetoothDevice(deviceId)) { + reconnectKnownBluetoothDevice(deviceId) + } else { + connectKnownDevice(deviceId, forceSession = true) + } } - connectKnownDevice(deviceId, forceSession = true) + result.requireUnlocked() } + private fun Result.requireUnlocked(): Result = fold( + onSuccess = { + if (it.pinProtection == true && it.unlocked == false) { + Result.failure(TrezorException.DeviceBusy()) + } else { + Result.success(it) + } + }, + onFailure = { Result.failure(it) }, + ) + /** * BLE Trezors often need a few seconds to advertise again after unlock, so retry * with growing delays (same cadence as [retryAutoReconnect]) instead of failing on @@ -1235,7 +1250,7 @@ class TrezorRepo @Inject constructor( } private fun trezorErrorMessage(error: Throwable): String? = - if (error.isTrezorDeviceBusy()) { + if (error.isTrezorLockedOrBusy()) { TrezorErrorPresenter.userMessage(context, error) } else { error.message diff --git a/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt b/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt index 85bcfafee2..22bf14b890 100644 --- a/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt +++ b/app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt @@ -2,11 +2,11 @@ package to.bitkit.utils import android.content.Context import to.bitkit.R -import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorLockedOrBusy object TrezorErrorPresenter { fun userMessage(context: Context, error: Throwable): String { - if (error.isTrezorDeviceBusy()) { + if (error.isTrezorLockedOrBusy()) { return context.getString(R.string.hardware__device_busy) } return userMessage( @@ -17,7 +17,7 @@ object TrezorErrorPresenter { } fun userMessage(context: Context, error: Throwable, fallback: String): String { - if (error.isTrezorDeviceBusy()) { + if (error.isTrezorLockedOrBusy()) { return context.getString(R.string.hardware__device_busy) } return error.message?.takeIf { it.isNotBlank() } ?: fallback diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 05aa1c171e..5f144cbc7d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -33,7 +33,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose -import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorLockedOrBusy import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction @@ -49,7 +49,6 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import to.bitkit.utils.TrezorErrorPresenter import javax.inject.Inject import kotlin.math.min import kotlin.math.roundToLong @@ -643,12 +642,11 @@ class TransferViewModel @Inject constructor( Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) return } - if (e.isTrezorDeviceBusy()) { - Logger.warn("Hardware transfer blocked by busy Trezor for '$deviceId'", e, context = TAG) + if (e.isTrezorLockedOrBusy() || e.isHardwareInteractionTimeout()) { + Logger.warn("Blocked hardware transfer for locked or busy Trezor '$deviceId'", e, context = TAG) ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = TrezorErrorPresenter.userMessage(context, e), + type = Toast.ToastType.INFO, + title = context.getString(R.string.hardware__device_busy), ) return } @@ -672,6 +670,10 @@ class TransferViewModel @Inject constructor( } } + private fun Throwable.isHardwareInteractionTimeout(): Boolean = + this is HardwareFundingError && + generateSequence(this) { it.cause }.any { it is TimeoutCancellationException } + private suspend fun showHardwareReconnectError(deviceId: String) { if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { ToastEventBus.send( diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index 1900be86da..fb31edeee7 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -45,4 +45,17 @@ class TrezorExceptionExtTest { assertFalse(TrezorException.UserCancelled().isTrezorDeviceBusy()) assertFalse(AppError("sign failed").isTrezorDeviceBusy()) } + + @Test + fun `isTrezorLockedOrBusy recognizes locked device firmware response`() { + val error = AppError("Device error (code 99): Firmware error") + + assertTrue(error.isTrezorLockedOrBusy()) + } + + @Test + fun `isTrezorLockedOrBusy rejects unrelated firmware errors`() { + assertFalse(AppError("Firmware error").isTrezorLockedOrBusy()) + assertFalse(AppError("Device error (code 98): Firmware error").isTrezorLockedOrBusy()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index a10d69c1af..9369e47b56 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -144,9 +144,13 @@ class TrezorRepoTest : BaseUnitTest() { private fun mockFeatures( label: String? = DEVICE_LABEL, model: String? = DEVICE_MODEL, + pinProtection: Boolean? = null, + unlocked: Boolean? = null, ): TrezorFeatures = mock { on { this.label }.thenReturn(label) on { this.model }.thenReturn(model) + on { this.pinProtection }.thenReturn(pinProtection) + on { this.unlocked }.thenReturn(unlocked) } private fun mockPublicKeyResponse( @@ -1518,6 +1522,25 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).disconnect() } + @Test + fun `ensureConnected returns device busy when current device is locked`() = test { + val features = mockFeatures(pinProtection = true, unlocked = false) + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + whenever(trezorService.isConnected()).thenReturn(true) + + val result = sut.ensureConnected(DEVICE_ID) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.isTrezorDeviceBusy() == true) + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + @Test fun `ensureConnected retries bluetooth reconnect until scan finds the device`() = test { val bleDeviceId = "ble:57:21:A7:F9:DD:AD" diff --git a/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt b/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt index e02ad0a6f5..55d0c523c0 100644 --- a/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt +++ b/app/src/test/java/to/bitkit/utils/TrezorErrorPresenterTest.kt @@ -27,6 +27,13 @@ class TrezorErrorPresenterTest { assertEquals(deviceBusyMessage, message) } + @Test + fun `userMessage maps locked device firmware response to unlock prompt`() { + val message = TrezorErrorPresenter.userMessage(context, AppError("Device error (code 99): Firmware error")) + + assertEquals(deviceBusyMessage, message) + } + @Test fun `userMessage returns error message when present`() { assertEquals("connect failed", TrezorErrorPresenter.userMessage(context, AppError("connect failed"))) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index fb5486f698..66270e809a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -384,7 +384,6 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())) .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.common__error)).thenReturn(ERROR_TITLE) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") @@ -393,9 +392,58 @@ class TransferViewModelTest : BaseUnitTest() { toastJob.cancel() assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(ERROR_TITLE, toasts.single().title) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().description) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm shows unlock prompt for locked device firmware response`() = test { + val order = previewBtOrder() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) + .thenReturn(Result.failure(AppError("Device error (code 99): Firmware error"))) + whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(1, toasts.size) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm shows unlock prompt when composition times out`() = test { + val order = previewBtOrder() + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + 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(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) + .thenReturn(Result.failure(timeout)) + whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(1, toasts.size) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) + verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -408,7 +456,6 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(AppError(TrezorException.DeviceBusy()))) - whenever(context.getString(R.string.common__error)).thenReturn(ERROR_TITLE) whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") @@ -417,9 +464,8 @@ class TransferViewModelTest : BaseUnitTest() { toastJob.cancel() assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.ERROR, toasts.single().type) - assertEquals(ERROR_TITLE, toasts.single().title) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().description) + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) } @@ -478,7 +524,6 @@ class TransferViewModelTest : BaseUnitTest() { 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 ERROR_TITLE = "Error" const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1030.fixed.md index e35c122dcd..739a4c75ab 100644 --- a/changelog.d/next/1030.fixed.md +++ b/changelog.d/next/1030.fixed.md @@ -1 +1 @@ -Fixed repeated Trezor pairing prompts and Bluetooth-off recovery during hardware wallet connections. +Fixed repeated Trezor pairing prompts, Bluetooth-off recovery, and locked-device guidance during hardware wallet operations. From c4d353dc423e5b7ae2f1313095802bd73002ad6a Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 13:30:44 +0200 Subject: [PATCH 03/10] fix: retry signed trezor broadcasts --- .../java/to/bitkit/models/HardwareWallet.kt | 7 + .../to/bitkit/repositories/HwWalletRepo.kt | 28 +++- .../transfer/hardware/SpendingHwSignScreen.kt | 31 ++++- .../to/bitkit/viewmodels/TransferViewModel.kt | 103 +++++++++++--- .../bitkit/repositories/HwWalletRepoTest.kt | 34 ++++- .../viewmodels/TransferViewModelTest.kt | 126 ++++++++++++++++-- changelog.d/next/1030.fixed.md | 2 +- 7 files changed, 293 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index c0d19e1a01..78c1ea5876 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -65,6 +65,13 @@ data class HwFundingTransaction( val satsPerVByte: ULong, ) +data class HwFundingSignedTx( + val serializedTx: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) + data class HwFundingBroadcastResult( val txId: String, val miningFeeSats: ULong, diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index cbe3166289..c40a614419 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -41,6 +41,7 @@ import to.bitkit.ext.timestamp import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx @@ -222,11 +223,11 @@ class HwWalletRepo @Inject constructor( } } - /** Signs a composed funding payment on the Trezor and broadcasts it. */ - suspend fun signAndBroadcastFunding( + /** Signs a composed funding payment on the Trezor. */ + suspend fun signFunding( deviceId: String, funding: HwFundingTransaction, - ): Result = withContext(ioDispatcher) { + ): Result = withContext(ioDispatcher) { runSuspendCatching { val signed = runSuspendCatching { trezorRepo.signTxFromPsbt( @@ -240,9 +241,9 @@ class HwWalletRepo @Inject constructor( trezorRepo.disconnectStaleSession(deviceId) } } - val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() - HwFundingBroadcastResult( - txId = txId, + val signedTx = signed.getOrThrow() + HwFundingSignedTx( + serializedTx = signedTx.serializedTx, miningFeeSats = funding.miningFeeSats, feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, @@ -250,6 +251,21 @@ class HwWalletRepo @Inject constructor( } } + /** Broadcasts a signed funding payment without requiring the hardware device. */ + suspend fun broadcastFunding( + signedTx: HwFundingSignedTx, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrThrow() + HwFundingBroadcastResult( + txId = txId, + miningFeeSats = signedTx.miningFeeSats, + feeRate = signedTx.feeRate, + totalSpent = signedTx.totalSpent, + ) + } + } + suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) /** 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 25544379df..ac8785ce9c 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 @@ -72,6 +72,7 @@ fun SpendingHwSignScreen( order = order, isAdvanced = state.isAdvanced, isSigning = state.isSigning, + hasPendingBroadcast = state.hasPendingHwBroadcast, onBackClick = onBackClick, onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, @@ -85,6 +86,7 @@ private fun Content( order: IBtOrder, isAdvanced: Boolean = false, isSigning: Boolean = false, + hasPendingBroadcast: Boolean = false, onBackClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, @@ -111,7 +113,13 @@ private fun Content( ) { VerticalSpacer(32.dp) Display( - text = stringResource(R.string.lightning__transfer_hw__sign_title) + text = stringResource( + if (hasPendingBroadcast) { + R.string.lightning__transfer_hw__signed_title + } else { + R.string.lightning__transfer_hw__sign_title + } + ) .withAccent(accentColor = Colors.Purple), modifier = Modifier.fillMaxWidth() ) @@ -126,6 +134,7 @@ private fun Content( text = stringResource(R.string.common__learn_more), size = ButtonSize.Small, fullWidth = false, + enabled = !isSigning && !hasPendingBroadcast, onClick = onLearnMoreClick, modifier = Modifier.testTag("HardwareTransferSignLearnMore") ) @@ -135,6 +144,7 @@ private fun Content( ), size = ButtonSize.Small, fullWidth = false, + enabled = !isSigning && !hasPendingBroadcast, onClick = { if (isAdvanced) onUseDefaultLspBalanceClick() else onAdvancedClick() }, modifier = Modifier.testTag( if (isAdvanced) "HardwareTransferSignDefault" else "HardwareTransferSignAdvanced" @@ -145,7 +155,13 @@ private fun Content( FillHeight() PrimaryButton( - text = stringResource(R.string.lightning__transfer_hw__open_connect), + text = stringResource( + if (hasPendingBroadcast) { + R.string.common__retry + } else { + R.string.lightning__transfer_hw__open_connect + } + ), onClick = onOpenConnect, enabled = !isSigning, isLoading = isSigning, @@ -223,3 +239,14 @@ private fun PreviewSigning() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewPendingBroadcast() { + AppThemeSurface { + Content( + order = previewBtOrder(), + hasPendingBroadcast = true, + ) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5f144cbc7d..ee4d23dfea 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -36,6 +36,7 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.isTrezorLockedOrBusy import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -96,6 +97,7 @@ class TransferViewModel @Inject constructor( fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } var maxLspFee = 0uL private var hwTransferSignJob: Job? = null + private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null // region Spending @@ -386,7 +388,15 @@ class TransferViewModel @Inject constructor( private suspend fun onOrderCreated(order: IBtOrder) { settingsStore.update { it.copy(lightningSetupStep = 0) } - _spendingUiState.update { it.copy(order = order, isAdvanced = false, defaultOrder = null) } + pendingHwFundingBroadcast = null + _spendingUiState.update { + it.copy( + order = order, + isAdvanced = false, + defaultOrder = null, + hasPendingHwBroadcast = false, + ) + } setTransferEffect(TransferEffect.OnOrderCreated) } @@ -489,6 +499,7 @@ class TransferViewModel @Inject constructor( fun resetSpendingState() { hwTransferSignJob?.cancel() hwTransferSignJob = null + pendingHwFundingBroadcast = null _spendingUiState.update { TransferToSpendingUiState() } _transferValues.update { TransferValues() } } @@ -541,8 +552,10 @@ class TransferViewModel @Inject constructor( return@launch } - signTransferToSpendingWithHardware(order, deviceId, address) + signAndBroadcastHardwareFunding(order, deviceId, address) .onSuccess { result -> + pendingHwFundingBroadcast = null + _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } fundPaidOrder( order = order, txId = result.txId, @@ -560,21 +573,20 @@ class TransferViewModel @Inject constructor( } } - private suspend fun signTransferToSpendingWithHardware( + private suspend fun signAndBroadcastHardwareFunding( order: IBtOrder, deviceId: String, address: String, ): Result { val result = runCatching { - ensureHardwareConnected(deviceId) - val satsPerVByte = hwFundingSatsPerVByte() - val funding = composeHardwareFundingTransaction( - deviceId = deviceId, - address = address, - sats = order.feeSat, - satsPerVByte = satsPerVByte, - ) - signAndBroadcastHardwareFunding(deviceId, funding) + val signedTx = pendingHwFundingBroadcast + ?.takeIf { it.orderId == order.id } + ?.signedTx + ?: prepareSignedHardwareFunding(order, deviceId, address).also { + pendingHwFundingBroadcast = PendingHwFundingBroadcast(orderId = order.id, signedTx = it) + _spendingUiState.update { state -> state.copy(hasPendingHwBroadcast = true) } + } + broadcastHardwareFunding(signedTx) } result.exceptionOrNull()?.let { if (it is CancellationException && it !is TimeoutCancellationException) throw it @@ -582,6 +594,23 @@ class TransferViewModel @Inject constructor( return result } + private suspend fun prepareSignedHardwareFunding( + order: IBtOrder, + deviceId: String, + address: String, + ): HwFundingSignedTx { + ensureHardwareConnected(deviceId) + val satsPerVByte = hwFundingSatsPerVByte() + val funding = composeHardwareFundingTransaction( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = satsPerVByte, + ) + return signHardwareFunding(deviceId, funding) + } + + @Suppress("ThrowsCount") private suspend fun ensureHardwareConnected(deviceId: String) { runCatching { withTimeout(HW_RECONNECT_TIMEOUT) { @@ -616,13 +645,13 @@ class TransferViewModel @Inject constructor( } @Suppress("ThrowsCount") - private suspend fun signAndBroadcastHardwareFunding( + private suspend fun signHardwareFunding( deviceId: String, funding: HwFundingTransaction, - ): HwFundingBroadcastResult { + ): HwFundingSignedTx { return runCatching { withTimeout(HW_SIGN_TIMEOUT) { - hwWalletRepo.signAndBroadcastFunding( + hwWalletRepo.signFunding( deviceId = deviceId, funding = funding, ).getOrThrow() @@ -637,6 +666,19 @@ class TransferViewModel @Inject constructor( } } + private suspend fun broadcastHardwareFunding( + signedTx: HwFundingSignedTx, + ): HwFundingBroadcastResult { + return runCatching { + withTimeout(HW_BROADCAST_TIMEOUT) { + hwWalletRepo.broadcastFunding(signedTx).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareBroadcastError(it) + } + } + private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { if (e.isTrezorUserCancellation()) { Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) @@ -651,6 +693,10 @@ class TransferViewModel @Inject constructor( return } when (e) { + is HardwareBroadcastError -> { + Logger.warn("Hardware funding transaction is signed but not confirmed broadcast", e, context = TAG) + showHardwareBroadcastError(e) + } is HardwareReconnectError -> { Logger.error("Failed to reconnect hardware device", e, context = TAG) showHardwareReconnectError(deviceId) @@ -670,6 +716,23 @@ class TransferViewModel @Inject constructor( } } + private suspend fun showHardwareBroadcastError(error: HardwareBroadcastError) { + if (!error.isHardwareBroadcastConnectivityFailure()) { + ToastEventBus.send(error.cause ?: error) + return + } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__connection_issue), + description = context.getString(R.string.other__connection_issues_explain), + ) + } + + private fun Throwable.isHardwareBroadcastConnectivityFailure(): Boolean = + generateSequence(this) { it.cause }.any { + it is TimeoutCancellationException || "Failed to connect to Electrum" in it.message.orEmpty() + } + private fun Throwable.isHardwareInteractionTimeout(): Boolean = this is HardwareFundingError && generateSequence(this) { it.cause }.any { it is TimeoutCancellationException } @@ -946,6 +1009,9 @@ class TransferViewModel @Inject constructor( /** Upper bound for one hardware signing attempt before the UI releases the button. */ private val HW_SIGN_TIMEOUT = 120.seconds + + /** Upper bound for broadcasting a signed hardware funding transaction. */ + private val HW_BROADCAST_TIMEOUT = 120.seconds const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -956,6 +1022,12 @@ class TransferViewModel @Inject constructor( private class HardwareReconnectError(cause: Throwable) : AppError(cause) private class HardwareFundingError(cause: Throwable) : AppError(cause) private class HardwareSigningTimeoutError(cause: Throwable) : AppError(cause) +private class HardwareBroadcastError(cause: Throwable) : AppError(cause) + +private data class PendingHwFundingBroadcast( + val orderId: String, + val signedTx: HwFundingSignedTx, +) // region state data class TransferToSpendingUiState( @@ -967,6 +1039,7 @@ data class TransferToSpendingUiState( val quarterAmount: Long = 0, val isLoading: Boolean = false, val isSigning: Boolean = false, + val hasPendingHwBroadcast: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 29294a0e3b..ede1d9c799 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -33,6 +33,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice @@ -945,7 +946,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signAndBroadcastFunding returns txid and composed fee data`() = test { + fun `signFunding returns signed transaction and composed fee data`() = test { val signedTx = TrezorSignedTx( signatures = emptyList(), serializedTx = "rawtx", @@ -960,20 +961,41 @@ class HwWalletRepoTest : BaseUnitTest() { ) whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) .thenReturn(Result.success(signedTx)) + val sut = createRepo() + + val result = sut.signFunding("dev1", funding) + + assertEquals(true, result.isSuccess) + assertEquals("rawtx", result.getOrThrow().serializedTx) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(3uL, result.getOrThrow().feeRate) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + + @Test + fun `broadcastFunding returns txid and signed fee data`() = test { + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = 1_250uL, + feeRate = 3uL, + totalSpent = 26_250uL, + ) whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.broadcastFunding(signedTx) assertEquals(true, result.isSuccess) assertEquals("broadcast-txid", result.getOrThrow().txId) assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) + verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) } @Test - fun `signAndBroadcastFunding disconnects stale session when sign fails`() = test { + fun `signFunding disconnects stale session when sign fails`() = test { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, @@ -986,7 +1008,7 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.signFunding("dev1", funding) assertEquals(true, result.isFailure) verify(trezorRepo).disconnectStaleSession("dev1") @@ -994,7 +1016,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signAndBroadcastFunding keeps session when user cancels on device`() = test { + fun `signFunding keeps session when user cancels on device`() = test { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, @@ -1006,7 +1028,7 @@ class HwWalletRepoTest : BaseUnitTest() { .thenReturn(Result.failure(TrezorException.UserCancelled())) val sut = createRepo() - val result = sut.signAndBroadcastFunding("dev1", funding) + val result = sut.signFunding("dev1", funding) assertEquals(true, result.isFailure) verify(trezorRepo, never()).disconnectStaleSession(any()) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 66270e809a..d14b300812 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -24,6 +24,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R @@ -35,6 +36,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.Toast @@ -221,13 +223,15 @@ class TransferViewModelTest : BaseUnitTest() { feeRate = FEE_RATE, totalSpent = order.feeSat + MINING_FEE, ) + val signed = signedFunding(funding) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -238,7 +242,8 @@ class TransferViewModelTest : BaseUnitTest() { eq(order.feeSat), eq(FEE_RATE), ) - verify(hwWalletRepo).signAndBroadcastFunding(eq(DEVICE_ID), eq(funding)) + verify(hwWalletRepo).signFunding(eq(DEVICE_ID), eq(funding)) + verify(hwWalletRepo).broadcastFunding(signed) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(transferRepo).createTransfer( eq(TransferType.TO_SPENDING), @@ -275,6 +280,7 @@ class TransferViewModelTest : BaseUnitTest() { feeRate = FALLBACK_FEE_RATE, totalSpent = order.feeSat + MINING_FEE, ) + val signed = signedFunding(funding, feeRate = FALLBACK_FEE_RATE) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) @@ -282,7 +288,8 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) .thenReturn(Result.failure(AppError("fee unavailable"))) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() @@ -309,7 +316,8 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(DEVICE_ID) verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) - verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) } @Test @@ -329,7 +337,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.failure(timeout)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.failure(timeout)) whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -355,7 +363,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())) + whenever(hwWalletRepo.signFunding(any(), any())) .thenReturn(Result.failure(TrezorException.UserCancelled())) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) @@ -382,7 +390,7 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())) + whenever(hwWalletRepo.signFunding(any(), any())) .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") @@ -443,7 +451,8 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(1, toasts.size) assertEquals(Toast.ToastType.INFO, toasts.single().type) assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) - verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -490,6 +499,105 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm retries broadcast without signing again`() = 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) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + 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(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.broadcastFunding(signed)) + .thenReturn( + Result.failure(AppError("Failed to connect to Electrum: DNS lookup failed")), + Result.success(broadcast), + ) + 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) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) + verify(cacheStore, never()).addPaidOrder(any(), any()) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo, times(1)).ensureConnected(DEVICE_ID) + verify(hwWalletRepo, times(1)).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, times(1)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo, times(2)).broadcastFunding(signed) + verify(cacheStore).addPaidOrder(order.id, TXID) + } + + @Test + fun `onTransferToSpendingHwConfirm keeps signed transaction when broadcast times out`() = 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) + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + 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(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.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) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + verify(hwWalletRepo, never()).disconnectStaleSession(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + private fun signedFunding( + funding: HwFundingTransaction, + feeRate: ULong = FEE_RATE, + ) = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = funding.miningFeeSats, + feeRate = feeRate, + totalSpent = funding.totalSpent, + ) + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", @@ -524,6 +632,8 @@ class TransferViewModelTest : BaseUnitTest() { 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 XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1030.fixed.md index 739a4c75ab..dcc96e5ba7 100644 --- a/changelog.d/next/1030.fixed.md +++ b/changelog.d/next/1030.fixed.md @@ -1 +1 @@ -Fixed repeated Trezor pairing prompts, Bluetooth-off recovery, and locked-device guidance during hardware wallet operations. +Fixed repeated Trezor pairing prompts, Bluetooth-off recovery, locked-device guidance, and retrying signed transfers after network failures. From 12a5f660b469dc1ced8c70e5614404bf6ccce267 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 14:40:51 +0200 Subject: [PATCH 04/10] fix: clarify trezor connection guidance --- .cursor/rules/rules.main.mdc | 2 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 3 ++- app/src/main/res/values/strings.xml | 2 +- .../viewmodels/TransferViewModelTest.kt | 25 +++++++++++++++++++ changelog.d/next/1030.fixed.md | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.cursor/rules/rules.main.mdc b/.cursor/rules/rules.main.mdc index 39f159d04b..fa5a3ccd86 100644 --- a/.cursor/rules/rules.main.mdc +++ b/.cursor/rules/rules.main.mdc @@ -19,7 +19,7 @@ alwaysApply: true - use official kotlin code guide - always use trailing commas in parameters list, except after modifiers parameter - when invoking any composable function, always pass the modifier argument last in the call, sse named parameters if needed to ensure this order, and omit trailing commas after it. -- never modify `strings.xml` +- English source copy may be updated in `app/src/main/res/values/strings.xml`; never edit localized `values-*/strings.xml` files directly - always keep compose preview at end of file - split screen composables into stateful wrapper parent and stateless child which can be rendered in the previews. - wrap previews in `AppThemeSurface` composable, and name them simple, like `Preview`, `Preview2`. diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index ee4d23dfea..b127c8a340 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -741,7 +741,8 @@ class TransferViewModel @Inject constructor( if (hwWalletRepo.isKnownBluetoothDevice(deviceId)) { ToastEventBus.send( type = Toast.ToastType.INFO, - title = context.getString(R.string.hardware__connect_error), + title = context.getString(R.string.hardware__connect_title), + description = context.getString(R.string.hardware__connect_error), ) return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4f18f5169..60b5feb970 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,7 +167,7 @@ Open Settings Bitkit needs the nearby-devices (Bluetooth) permission to find your hardware wallet. Enable it in Settings, then try again. Bluetooth access needed - Could not connect to your Trezor. Check that it is unlocked and try again. + Could not connect to your hardware device. Make sure it is unlocked, nearby, and not connected to another phone or computer, then try again. Searching for <accent>devices</accent> Please connect your hardware wallet now via USB or Bluetooth. Connect Device diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index d14b300812..9582256533 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -320,6 +320,29 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).broadcastFunding(any()) } + @Test + fun `onTransferToSpendingHwConfirm shows connection guidance for bluetooth reconnect failure`() = test { + val order = previewBtOrder() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.failure(RuntimeException("no device"))) + whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_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) + advanceUntilIdle() + toastJob.cancel() + + assertEquals(Toast.ToastType.INFO, toasts.single().type) + assertEquals(CONNECT_TITLE, toasts.single().title) + assertEquals(CONNECT_DESCRIPTION, toasts.single().description) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm disconnects stale session when signing times out`() = test { val order = previewBtOrder() @@ -634,6 +657,8 @@ class TransferViewModelTest : BaseUnitTest() { 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 XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1030.fixed.md index dcc96e5ba7..681b7231a3 100644 --- a/changelog.d/next/1030.fixed.md +++ b/changelog.d/next/1030.fixed.md @@ -1 +1 @@ -Fixed repeated Trezor pairing prompts, Bluetooth-off recovery, locked-device guidance, and retrying signed transfers after network failures. +Improved Trezor pairing retries, Bluetooth recovery, connection and unlock guidance, and signed-transfer broadcast retries. From a3a8832be514dd3fb6a16b09e85d051c4c856f85 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 15:16:29 +0200 Subject: [PATCH 05/10] fix: cancel abandoned trezor transfers --- .../transfer/hardware/SpendingHwSignScreen.kt | 5 ++++ .../to/bitkit/viewmodels/TransferViewModel.kt | 9 ++++++-- .../viewmodels/TransferViewModelTest.kt | 23 +++++++++++++++++++ changelog.d/next/1030.fixed.md | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index ac8785ce9c..d795b9abe4 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -59,6 +60,10 @@ fun SpendingHwSignScreen( viewModel.warmUpHardwareConnection(deviceId) } + DisposableEffect(viewModel) { + onDispose(viewModel::cancelHardwareTransfer) + } + LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index b127c8a340..9b929df52d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -497,11 +497,16 @@ class TransferViewModel @Inject constructor( } fun resetSpendingState() { + cancelHardwareTransfer() + _spendingUiState.update { TransferToSpendingUiState() } + _transferValues.update { TransferValues() } + } + + fun cancelHardwareTransfer() { hwTransferSignJob?.cancel() hwTransferSignJob = null pendingHwFundingBroadcast = null - _spendingUiState.update { TransferToSpendingUiState() } - _transferValues.update { TransferValues() } + _spendingUiState.update { it.copy(isSigning = false, hasPendingHwBroadcast = false) } } // endregion diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 9582256533..bdc5491078 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -9,17 +9,20 @@ import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeStatus import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock @@ -320,6 +323,26 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).broadcastFunding(any()) } + @Test + fun `cancelHardwareTransfer stops an in-flight hardware transfer`() = test { + val order = previewBtOrder() + val connectResult = CompletableDeferred>() + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)).doSuspendableAnswer { connectResult.await() } + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + runCurrent() + assertEquals(true, sut.spendingUiState.value.isSigning) + + sut.cancelHardwareTransfer() + runCurrent() + + assertEquals(false, sut.spendingUiState.value.isSigning) + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + @Test fun `onTransferToSpendingHwConfirm shows connection guidance for bluetooth reconnect failure`() = test { val order = previewBtOrder() diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1030.fixed.md index 681b7231a3..349a6902cd 100644 --- a/changelog.d/next/1030.fixed.md +++ b/changelog.d/next/1030.fixed.md @@ -1 +1 @@ -Improved Trezor pairing retries, Bluetooth recovery, connection and unlock guidance, and signed-transfer broadcast retries. +Improved Trezor pairing, Bluetooth recovery, connection and unlock guidance, transfer cancellation, and signed-broadcast retries. From 916a1eff1497b614202f1edf6b63dd78471fe457 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 15:30:42 +0200 Subject: [PATCH 06/10] chore: rename changelog fragment Co-authored-by: Cursor --- changelog.d/next/{1030.fixed.md => 1067.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1030.fixed.md => 1067.fixed.md} (100%) diff --git a/changelog.d/next/1030.fixed.md b/changelog.d/next/1067.fixed.md similarity index 100% rename from changelog.d/next/1030.fixed.md rename to changelog.d/next/1067.fixed.md From d132d02a3be0456ccd8e44572cd2bb499f65ce7d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 16:05:33 +0200 Subject: [PATCH 07/10] fix: address trezor review feedback Co-authored-by: Cursor --- .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../to/bitkit/repositories/HwWalletRepo.kt | 21 ++- .../java/to/bitkit/repositories/TrezorRepo.kt | 12 +- .../java/to/bitkit/services/TrezorDebugLog.kt | 5 +- .../java/to/bitkit/services/TrezorService.kt | 7 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 13 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 67 ++++++--- .../bitkit/repositories/HwWalletRepoTest.kt | 19 +++ .../to/bitkit/repositories/TrezorRepoTest.kt | 23 +++ .../to/bitkit/services/TrezorDebugLogTest.kt | 16 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 12 ++ .../viewmodels/TransferViewModelTest.kt | 142 +++++++++++++++++- 12 files changed, 309 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index 78c1ea5876..e6f93b6165 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -70,6 +70,7 @@ data class HwFundingSignedTx( val miningFeeSats: ULong, val feeRate: ULong, val totalSpent: ULong, + val txId: String? = null, ) data class HwFundingBroadcastResult( diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index c40a614419..bdea93450a 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -88,6 +88,16 @@ class HwWalletRepo @Inject constructor( /** Trezor v1 (2.4.0) tracks native segwit only; multi-type HW support is follow-up work. */ private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey) + private val ALREADY_BROADCAST_MARKERS = listOf( + "already in block chain", + "already in blockchain", + "already in mempool", + "already-in-block-chain", + "already-in-mempool", + "txn-already-known", + "transaction already exists", + "already known", + ) } private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) @@ -247,6 +257,7 @@ class HwWalletRepo @Inject constructor( miningFeeSats = funding.miningFeeSats, feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, + txId = signedTx.txid, ) } } @@ -256,7 +267,9 @@ class HwWalletRepo @Inject constructor( signedTx: HwFundingSignedTx, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrThrow() + val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrElse { + signedTx.txId?.takeIf { _ -> it.isAlreadyBroadcastError() } ?: throw it + } HwFundingBroadcastResult( txId = txId, miningFeeSats = signedTx.miningFeeSats, @@ -266,6 +279,12 @@ class HwWalletRepo @Inject constructor( } } + private fun Throwable.isAlreadyBroadcastError(): Boolean { + return generateSequence(this) { it.cause }.any { error -> + ALREADY_BROADCAST_MARKERS.any { it in error.message.orEmpty().lowercase() } + } + } + suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) /** diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index fc71c46d19..8bc72854e1 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -720,7 +720,7 @@ class TrezorRepo @Inject constructor( } suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { - val result = awaitConnectedOrNull(deviceId)?.let { Result.success(it) } ?: run { + val result = awaitConnectedOrNull(deviceId)?.let { refreshLockedFeatures(deviceId, it) } ?: run { if (isKnownBluetoothDevice(deviceId)) { reconnectKnownBluetoothDevice(deviceId) } else { @@ -730,6 +730,16 @@ class TrezorRepo @Inject constructor( result.requireUnlocked() } + private suspend fun refreshLockedFeatures( + deviceId: String, + 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)) } + } + } + private fun Result.requireUnlocked(): Result = fold( onSuccess = { if (it.pinProtection == true && it.unlocked == false) { diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt index e589b5e33c..e030f08cf8 100644 --- a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -16,8 +16,9 @@ object TrezorDebugLog { private const val TAG = "TrezorDebugLog" private const val MAX_LINES = 300 private val secretValuePattern = Regex( - """(?i)\b(mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|extended[ _-]?key|psbt|""" + - """raw[ _-]?tx|serialized[ _-]?tx)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)""", + """(?i)["']?\b(mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|""" + + """extended[ _-]?key|psbt|raw[ _-]?tx|serialized[ _-]?tx)\b["']?\s*[:=]\s*""" + + """("[^"]*"|'[^']*'|[^\s,;}]+)""", ) private val _lines = MutableStateFlow>(persistentListOf()) val lines: StateFlow> = _lines.asStateFlow() diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8d9e97fe9c..5760c5ce84 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -40,6 +40,7 @@ import com.synonym.bitkitcore.trezorInitialize import com.synonym.bitkitcore.trezorIsConnected import com.synonym.bitkitcore.trezorIsInitialized import com.synonym.bitkitcore.trezorListDevices +import com.synonym.bitkitcore.trezorRefreshFeatures import com.synonym.bitkitcore.trezorScan import com.synonym.bitkitcore.trezorSetTransportCallback import com.synonym.bitkitcore.trezorSetUiCallback @@ -126,6 +127,12 @@ class TrezorService @Inject constructor( } } + suspend fun refreshFeatures(): TrezorFeatures { + return ServiceQueue.CORE.background { + trezorRefreshFeatures() + } + } + suspend fun getAddress( path: String, coin: TrezorCoinType? = TrezorCoinType.BITCOIN, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6e43fbb424..b7837970e5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3109,9 +3109,16 @@ class AppViewModel @Inject constructor( return } - // The Connect Hardware flow is itself a Hardware sheet and drives the pair-code step - // inline within its own NavHost; replacing it here would tear down that back stack. - if (_currentSheet.value is Sheet.Hardware) return + // The Connect Hardware flow drives pair-code requests through its own NavHost. An app-wide + // PairCode sheet has no connect back stack to preserve, so replace its start route when the + // device requests a new code and force the input state to reset. + val currentSheet = _currentSheet.value + if (currentSheet is Sheet.Hardware) { + if (currentSheet.route is HardwareRoute.PairCode && currentSheet.route.requestId != requestId) { + showSheet(Sheet.Hardware(route = HardwareRoute.PairCode(requestId))) + } + return + } queuedPairingCodeRequestId = null showSheet(Sheet.Hardware(route = HardwareRoute.PairCode(requestId))) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 9b929df52d..147d4ceb1b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -497,16 +497,18 @@ class TransferViewModel @Inject constructor( } fun resetSpendingState() { - cancelHardwareTransfer() + hwTransferSignJob?.cancel() + hwTransferSignJob = null + pendingHwFundingBroadcast = null _spendingUiState.update { TransferToSpendingUiState() } _transferValues.update { TransferValues() } } fun cancelHardwareTransfer() { + if (pendingHwFundingBroadcast != null) return hwTransferSignJob?.cancel() hwTransferSignJob = null - pendingHwFundingBroadcast = null - _spendingUiState.update { it.copy(isSigning = false, hasPendingHwBroadcast = false) } + _spendingUiState.update { it.copy(isSigning = false) } } // endregion @@ -559,16 +561,22 @@ class TransferViewModel @Inject constructor( signAndBroadcastHardwareFunding(order, deviceId, address) .onSuccess { result -> - pendingHwFundingBroadcast = null - _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } - fundPaidOrder( - order = order, - txId = result.txId, - createTransferActivity = true, - fee = result.miningFeeSats, - feeRate = result.feeRate, - ) - setTransferEffect(TransferEffect.OnHwTxSigned) + runCatching { + fundPaidOrder( + order = order, + txId = result.txId, + createTransferActivity = true, + fee = result.miningFeeSats, + feeRate = result.feeRate, + ) + }.onSuccess { + pendingHwFundingBroadcast = 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) + } } .onFailure { handleHardwareTransferFailure(it, deviceId) } } finally { @@ -585,10 +593,16 @@ class TransferViewModel @Inject constructor( ): Result { val result = runCatching { val signedTx = pendingHwFundingBroadcast - ?.takeIf { it.orderId == order.id } + ?.takeIf { it.matches(order, deviceId, address) } ?.signedTx ?: prepareSignedHardwareFunding(order, deviceId, address).also { - pendingHwFundingBroadcast = PendingHwFundingBroadcast(orderId = order.id, signedTx = it) + pendingHwFundingBroadcast = PendingHwFundingBroadcast( + orderId = order.id, + deviceId = deviceId, + address = address, + amountSats = order.feeSat, + signedTx = it, + ) _spendingUiState.update { state -> state.copy(hasPendingHwBroadcast = true) } } broadcastHardwareFunding(signedTx) @@ -689,7 +703,7 @@ class TransferViewModel @Inject constructor( Logger.info("Hardware transfer cancelled on device for '$deviceId'", context = TAG) return } - if (e.isTrezorLockedOrBusy() || e.isHardwareInteractionTimeout()) { + if (e.isTrezorLockedOrBusy()) { Logger.warn("Blocked hardware transfer for locked or busy Trezor '$deviceId'", e, context = TAG) ToastEventBus.send( type = Toast.ToastType.INFO, @@ -712,7 +726,11 @@ class TransferViewModel @Inject constructor( } is HardwareFundingError -> { Logger.warn("Failed to compose hardware transfer funding for '$deviceId'", e, context = TAG) - showHardwareFundingError(e) + if (e.isHardwareInteractionTimeout()) { + showHardwareConnectivityError() + } else { + showHardwareFundingError(e) + } } else -> { Logger.error("Hardware transfer failed", e, context = TAG) @@ -726,6 +744,10 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(error.cause ?: error) return } + showHardwareConnectivityError() + } + + private suspend fun showHardwareConnectivityError() { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__connection_issue), @@ -1032,8 +1054,17 @@ private class HardwareBroadcastError(cause: Throwable) : AppError(cause) private data class PendingHwFundingBroadcast( val orderId: String, + val deviceId: String, + val address: String, + val amountSats: ULong, val signedTx: HwFundingSignedTx, -) +) { + fun matches(order: IBtOrder, deviceId: String, address: String): Boolean = + orderId == order.id && + this.deviceId == deviceId && + this.address == address && + amountSats == order.feeSat +} // region state data class TransferToSpendingUiState( diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index ede1d9c799..75f327d2ac 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -967,6 +967,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) assertEquals("rawtx", result.getOrThrow().serializedTx) + assertEquals("signed-txid", result.getOrThrow().txId) assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) @@ -994,6 +995,24 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) } + @Test + fun `broadcastFunding recovers signed txid when transaction is already known`() = test { + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = 1_250uL, + feeRate = 3uL, + totalSpent = 26_250uL, + txId = "signed-txid", + ) + whenever(trezorRepo.broadcastRawTx("rawtx")) + .thenReturn(Result.failure(AppError("Broadcast failed: txn-already-in-mempool"))) + val sut = createRepo() + + val result = sut.broadcastFunding(signedTx) + + assertEquals("signed-txid", result.getOrThrow().txId) + } + @Test fun `signFunding disconnects stale session when sign fails`() = test { val funding = HwFundingTransaction( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 9369e47b56..8ce451bae0 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -1533,6 +1533,7 @@ class TrezorRepoTest : BaseUnitTest() { sut.scan() sut.connect(DEVICE_ID) whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.refreshFeatures()).thenReturn(features) val result = sut.ensureConnected(DEVICE_ID) @@ -1541,6 +1542,28 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) } + @Test + fun `ensureConnected refreshes cached locked features after device unlock`() = test { + val lockedFeatures = mockFeatures(pinProtection = true, unlocked = false) + val unlockedFeatures = mockFeatures(pinProtection = true, unlocked = true) + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(lockedFeatures) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.refreshFeatures()).thenReturn(unlockedFeatures) + + val result = sut.ensureConnected(DEVICE_ID) + + assertEquals(unlockedFeatures, result.getOrNull()) + assertEquals(unlockedFeatures, sut.state.value.connected?.features) + verify(trezorService).refreshFeatures() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + } + @Test fun `ensureConnected retries bluetooth reconnect until scan finds the device`() = test { val bleDeviceId = "ble:57:21:A7:F9:DD:AD" diff --git a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt index 80fe247553..c080a2e773 100644 --- a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt @@ -22,4 +22,20 @@ class TrezorDebugLogTest { assertFalse("1234" in line) assertFalse("hidden wallet" in line) } + + @Test + fun `structured sensitive diagnostic values are redacted`() { + TrezorDebugLog.log( + "STRUCTURED_REDACTION_TEST", + """{"xpub":"xpub-secret","psbt":"psbt-secret","raw_tx":"raw-secret","pin":"1234","passphrase":"hidden"}""", + ) + + val line = TrezorDebugLog.lines.value.last { "[STRUCTURED_REDACTION_TEST]" in it } + + assertFalse("xpub-secret" in line) + assertFalse("psbt-secret" in line) + assertFalse("raw-secret" in line) + assertFalse("1234" in line) + assertFalse("hidden" in line) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 001224b1f3..0775139d8a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -310,6 +310,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `new app-wide pairing request replaces the stale pair code route`() = test { + needsPairingCode.value = true + pairingCodeRequestId.value = 1L + advanceUntilIdle() + + pairingCodeRequestId.value = 2L + advanceUntilIdle() + + assertEquals(Sheet.Hardware(route = HardwareRoute.PairCode(2L)), sut.currentSheet.value) + } + @Test fun `pairing code request does not interrupt a high priority sheet`() = test { sut.showSheet(Sheet.Pin()) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index bdc5491078..369cca9785 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -64,6 +64,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class TransferViewModelTest : BaseUnitTest() { private lateinit var sut: TransferViewModel @@ -476,7 +477,7 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onTransferToSpendingHwConfirm shows unlock prompt when composition times out`() = test { + fun `onTransferToSpendingHwConfirm shows connection warning when composition times out`() = test { val order = previewBtOrder() val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException val toasts = mutableListOf() @@ -488,15 +489,17 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())) .thenReturn(Result.failure(timeout)) - whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) + 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) advanceUntilIdle() toastJob.cancel() assertEquals(1, toasts.size) - assertEquals(Toast.ToastType.INFO, toasts.single().type) - assertEquals(DEVICE_BUSY_MESSAGE, toasts.single().title) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) + assertEquals(CONNECTION_ISSUE_DESCRIPTION, toasts.single().description) verify(hwWalletRepo, never()).signFunding(any(), any()) verify(hwWalletRepo, never()).broadcastFunding(any()) verify(cacheStore, never()).addPaidOrder(any(), any()) @@ -599,6 +602,136 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore).addPaidOrder(order.id, TXID) } + @Test + fun `onTransferToSpendingHwConfirm signs again when pending order address changes`() = test { + var 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.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn(Result.failure(AppError("Failed to connect to Electrum: 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) + advanceUntilIdle() + + order = order.copy( + payment = requireNotNull(order.payment).copy( + onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), + ), + ) + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo, times(2)).signFunding(DEVICE_ID, funding) + verify(hwWalletRepo).composeFundingTransaction( + DEVICE_ID, + "bc1qnewdestination", + order.feeSat, + FEE_RATE, + ) + } + + @Test + fun `cancelHardwareTransfer keeps tracking after signing completes`() = 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) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + val broadcastResult = CompletableDeferred>() + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).doSuspendableAnswer { broadcastResult.await() } + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + runCurrent() + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + + sut.cancelHardwareTransfer() + broadcastResult.complete(Result.success(broadcast)) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(cacheStore).addPaidOrder(order.id, TXID) + verify(transferRepo).createTransfer( + eq(TransferType.TO_SPENDING), + eq(order.clientBalanceSat.toLong()), + isNull(), + eq(TXID), + eq(order.id), + isNull(), + isNull(), + isNull(), + ) + } + + @Test + fun `onTransferToSpendingHwConfirm retains signed transaction when bookkeeping fails`() = 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) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) + whenever(cacheStore.addPaidOrder(order.id, TXID)).thenThrow(RuntimeException("cache failed")) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(hwWalletRepo).broadcastFunding(signed) + verify(transferRepo, never()).createTransfer( + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `onTransferToSpendingHwConfirm keeps signed transaction when broadcast times out`() = test { val order = previewBtOrder() @@ -642,6 +775,7 @@ class TransferViewModelTest : BaseUnitTest() { miningFeeSats = funding.miningFeeSats, feeRate = feeRate, totalSpent = funding.totalSpent, + txId = TXID, ) private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( From 88fe961c3b028ffcb4dd54d99b8ae11b1f70d6b5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 16:47:37 +0200 Subject: [PATCH 08/10] fix: address trezor review follow-up Co-authored-by: Cursor --- .../ui/sheets/hardware/HardwareSheet.kt | 6 +++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 5 +++- .../viewmodels/TransferViewModelTest.kt | 25 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) 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 2aed73cc94..610c12b34f 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 @@ -124,6 +124,12 @@ fun HardwareSheet( onFinish = onFinish, ) + LaunchedEffect(sheet.route) { + if (sheet.route is HardwareRoute.PairCode) { + navController.navigateTo(sheet.route) + } + } + Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b7837970e5..d9c7c91c3c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3115,7 +3115,7 @@ class AppViewModel @Inject constructor( val currentSheet = _currentSheet.value if (currentSheet is Sheet.Hardware) { if (currentSheet.route is HardwareRoute.PairCode && currentSheet.route.requestId != requestId) { - showSheet(Sheet.Hardware(route = HardwareRoute.PairCode(requestId))) + _currentSheet.update { Sheet.Hardware(route = HardwareRoute.PairCode(requestId)) } } return } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 147d4ceb1b..138c8d1a39 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -35,6 +35,7 @@ import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose import to.bitkit.ext.isTrezorLockedOrBusy import to.bitkit.ext.isTrezorUserCancellation +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction @@ -561,7 +562,7 @@ class TransferViewModel @Inject constructor( signAndBroadcastHardwareFunding(order, deviceId, address) .onSuccess { result -> - runCatching { + runSuspendCatching { fundPaidOrder( order = order, txId = result.txId, @@ -741,6 +742,8 @@ class TransferViewModel @Inject constructor( private suspend fun showHardwareBroadcastError(error: HardwareBroadcastError) { if (!error.isHardwareBroadcastConnectivityFailure()) { + pendingHwFundingBroadcast = null + _spendingUiState.update { it.copy(hasPendingHwBroadcast = false) } ToastEventBus.send(error.cause ?: error) return } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 369cca9785..c53f588f10 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -767,6 +767,31 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm clears signed transaction after permanent broadcast failure`() = 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.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(DEVICE_ID, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(AppError("invalid transaction"))) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + private fun signedFunding( funding: HwFundingTransaction, feeRate: ULong = FEE_RATE, From 78eef902ef5f61a4e00b12f6e60a11d669e1aabd Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 17:01:55 +0200 Subject: [PATCH 09/10] fix: harden trezor review handling Co-authored-by: Cursor --- .../java/to/bitkit/models/HardwareWallet.kt | 2 +- .../to/bitkit/repositories/HwWalletRepo.kt | 5 +++-- .../java/to/bitkit/services/TrezorDebugLog.kt | 22 ++++++++++++++----- .../bitkit/repositories/HwWalletRepoTest.kt | 1 + .../to/bitkit/services/TrezorDebugLogTest.kt | 16 ++++++++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index e6f93b6165..61e5d92f4e 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -70,7 +70,7 @@ data class HwFundingSignedTx( val miningFeeSats: ULong, val feeRate: ULong, val totalSpent: ULong, - val txId: String? = null, + val txId: String, ) data class HwFundingBroadcastResult( diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index bdea93450a..113843b4c6 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -252,12 +252,13 @@ class HwWalletRepo @Inject constructor( } } val signedTx = signed.getOrThrow() + val txId = signedTx.txid ?: throw AppError("Signed hardware funding transaction has no txid") HwFundingSignedTx( serializedTx = signedTx.serializedTx, miningFeeSats = funding.miningFeeSats, feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, - txId = signedTx.txid, + txId = txId, ) } } @@ -268,7 +269,7 @@ class HwWalletRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrElse { - signedTx.txId?.takeIf { _ -> it.isAlreadyBroadcastError() } ?: throw it + if (it.isAlreadyBroadcastError()) signedTx.txId else throw it } HwFundingBroadcastResult( txId = txId, diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt index e030f08cf8..5c41bd6157 100644 --- a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -15,18 +15,18 @@ import java.util.Locale object TrezorDebugLog { private const val TAG = "TrezorDebugLog" private const val MAX_LINES = 300 - private val secretValuePattern = Regex( - """(?i)["']?\b(mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|""" + - """extended[ _-]?key|psbt|raw[ _-]?tx|serialized[ _-]?tx)\b["']?\s*[:=]\s*""" + - """("[^"]*"|'[^']*'|[^\s,;}]+)""", - ) + private const val SECRET_KEYS = "mnemonic|seed|passphrase|pin|pairing[ _-]?code|credential|xpub|" + + "extended[ _-]?key|psbt|raw[ _-]?tx|serialized[ _-]?tx" + private val quotedSecretValuePattern = Regex("""(?i)(["']?\b($SECRET_KEYS)\b["']?\s*[:=]\s*)("[^"]*"|'[^']*')""") + private val multiWordSecretValuePattern = Regex("""(?i)\b(mnemonic|seed|passphrase)\b\s*[:=]\s*[^,;}]+""") + private val secretValuePattern = Regex("""(?i)\b($SECRET_KEYS)\b\s*[:=]\s*[^\s,;}]+""") private val _lines = MutableStateFlow>(persistentListOf()) val lines: StateFlow> = _lines.asStateFlow() private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) fun log(tag: String, msg: String) { - val sanitizedMessage = secretValuePattern.replace(msg) { "${it.groupValues[1]}=" } + val sanitizedMessage = sanitize(msg) val ts = synchronized(fmt) { fmt.format(Date()) } val line = "$ts [$tag] $sanitizedMessage" _lines.update { current -> @@ -43,4 +43,14 @@ object TrezorDebugLog { fun clear() { _lines.update { persistentListOf() } } + + private fun sanitize(message: String): String { + val quotedRedacted = quotedSecretValuePattern.replace(message) { + "${it.groupValues[1]}" + } + val multiWordRedacted = multiWordSecretValuePattern.replace(quotedRedacted) { + "${it.groupValues[1]}=" + } + return secretValuePattern.replace(multiWordRedacted) { "${it.groupValues[1]}=" } + } } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 75f327d2ac..0601a9e3a7 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -981,6 +981,7 @@ class HwWalletRepoTest : BaseUnitTest() { miningFeeSats = 1_250uL, feeRate = 3uL, totalSpent = 26_250uL, + txId = "signed-txid", ) whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) val sut = createRepo() diff --git a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt index c080a2e773..7061c68327 100644 --- a/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorDebugLogTest.kt @@ -38,4 +38,20 @@ class TrezorDebugLogTest { assertFalse("1234" in line) assertFalse("hidden" in line) } + + @Test + fun `unquoted multi word sensitive diagnostic values are fully redacted`() { + TrezorDebugLog.log( + "MULTI_WORD_REDACTION_TEST", + "mnemonic=abandon ability able passphrase=my hidden wallet", + ) + + val line = TrezorDebugLog.lines.value.last { "[MULTI_WORD_REDACTION_TEST]" in it } + + assertContains(line, "mnemonic=") + assertFalse("abandon" in line) + assertFalse("ability" in line) + assertFalse("able" in line) + assertFalse("my hidden wallet" in line) + } } From 12e1e393e19f9fd9cdde336c82347b6c5a654c07 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 10 Jul 2026 17:53:40 +0200 Subject: [PATCH 10/10] fix: restore trezor funding signing --- .../java/to/bitkit/models/HardwareWallet.kt | 2 +- .../to/bitkit/repositories/HwWalletRepo.kt | 7 +++---- .../transfer/hardware/SpendingHwSignScreen.kt | 13 ++++++++++--- .../hardware/SpendingHwSignedScreen.kt | 12 ++++++++++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 15 +++++++++++++-- .../bitkit/repositories/HwWalletRepoTest.kt | 19 ++++++++++++------- .../viewmodels/TransferViewModelTest.kt | 9 +++++++-- 7 files changed, 56 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index 61e5d92f4e..e6f93b6165 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -70,7 +70,7 @@ data class HwFundingSignedTx( val miningFeeSats: ULong, val feeRate: ULong, val totalSpent: ULong, - val txId: String, + val txId: String? = null, ) data class HwFundingBroadcastResult( diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 113843b4c6..50e3343291 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -252,13 +252,12 @@ class HwWalletRepo @Inject constructor( } } val signedTx = signed.getOrThrow() - val txId = signedTx.txid ?: throw AppError("Signed hardware funding transaction has no txid") HwFundingSignedTx( serializedTx = signedTx.serializedTx, miningFeeSats = funding.miningFeeSats, feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, - txId = txId, + txId = signedTx.txid, ) } } @@ -268,8 +267,8 @@ class HwWalletRepo @Inject constructor( signedTx: HwFundingSignedTx, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrElse { - if (it.isAlreadyBroadcastError()) signedTx.txId else throw it + val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrElse { error -> + signedTx.txId?.takeIf { error.isAlreadyBroadcastError() } ?: throw error } HwFundingBroadcastResult( txId = txId, 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 d795b9abe4..0c787a8cb7 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 @@ -78,6 +79,7 @@ fun SpendingHwSignScreen( isAdvanced = state.isAdvanced, isSigning = state.isSigning, hasPendingBroadcast = state.hasPendingHwBroadcast, + miningFeeSats = state.hwMiningFeeSats, onBackClick = onBackClick, onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, @@ -92,6 +94,7 @@ private fun Content( isAdvanced: Boolean = false, isSigning: Boolean = false, hasPendingBroadcast: Boolean = false, + miningFeeSats: ULong = 0u, onBackClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, @@ -130,7 +133,10 @@ private fun Content( ) VerticalSpacer(16.dp) - SpendingHwFeeGrid(order = order) + SpendingHwFeeGrid( + order = order, + miningFeeSats = miningFeeSats, + ) VerticalSpacer(24.dp) @@ -182,6 +188,7 @@ private fun Content( internal fun SpendingHwFeeGrid( order: IBtOrder, modifier: Modifier = Modifier, + miningFeeSats: ULong = 0u, ) { Column(modifier = modifier) { Row( @@ -190,7 +197,7 @@ internal fun SpendingHwFeeGrid( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__network_fee), - amount = order.networkFeeSat.toLong(), + amount = (order.networkFeeSat.safe() + miningFeeSats.safe()).toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__lsp_fee), @@ -207,7 +214,7 @@ internal fun SpendingHwFeeGrid( ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), - amount = order.feeSat.toLong(), + amount = (order.feeSat.safe() + miningFeeSats.safe()).toLong(), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt index 707fc91544..3247830a29 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt @@ -51,12 +51,17 @@ fun SpendingHwSignedScreen( onContinue() } - Content(order = order, onBackClick = onCloseClick) + Content( + order = order, + miningFeeSats = state.hwMiningFeeSats, + onBackClick = onCloseClick, + ) } @Composable private fun Content( order: IBtOrder, + miningFeeSats: ULong = 0u, onBackClick: () -> Unit = {}, ) { ScreenColumn { @@ -85,7 +90,10 @@ private fun Content( ) VerticalSpacer(16.dp) - SpendingHwFeeGrid(order = order) + SpendingHwFeeGrid( + order = order, + miningFeeSats = miningFeeSats, + ) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 138c8d1a39..74cb2fbe80 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -396,6 +396,7 @@ class TransferViewModel @Inject constructor( isAdvanced = false, defaultOrder = null, hasPendingHwBroadcast = false, + hwMiningFeeSats = 0u, ) } setTransferEffect(TransferEffect.OnOrderCreated) @@ -494,7 +495,14 @@ class TransferViewModel @Inject constructor( fun onUseDefaultLspBalanceClick() { val defaultOrder = _spendingUiState.value.defaultOrder - _spendingUiState.update { it.copy(order = defaultOrder, defaultOrder = null, isAdvanced = false) } + _spendingUiState.update { + it.copy( + order = defaultOrder, + defaultOrder = null, + isAdvanced = false, + hwMiningFeeSats = 0u, + ) + } } fun resetSpendingState() { @@ -649,7 +657,7 @@ class TransferViewModel @Inject constructor( sats: ULong, satsPerVByte: ULong, ): HwFundingTransaction { - return runCatching { + val funding = runCatching { withTimeout(HW_COMPOSE_TIMEOUT) { hwWalletRepo.composeFundingTransaction( deviceId = deviceId, @@ -662,6 +670,8 @@ class TransferViewModel @Inject constructor( if (it is CancellationException && it !is TimeoutCancellationException) throw it throw HardwareFundingError(it) } + _spendingUiState.update { it.copy(hwMiningFeeSats = funding.miningFeeSats) } + return funding } @Suppress("ThrowsCount") @@ -1080,6 +1090,7 @@ data class TransferToSpendingUiState( val isLoading: Boolean = false, val isSigning: Boolean = false, val hasPendingHwBroadcast: Boolean = false, + val hwMiningFeeSats: ULong = 0u, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 0601a9e3a7..12bb0fbe8c 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -5,8 +5,8 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType -import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorException +import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -33,6 +33,7 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.ext.create import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx @@ -40,7 +41,6 @@ import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.ext.create import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -501,7 +501,10 @@ class HwWalletRepoTest : BaseUnitTest() { advanceTimeBy(30.seconds) runCurrent() - verify(trezorRepo, times(2)).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) + verify( + trezorRepo, + times(2) + ).startWatcher(eq("dev1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -650,7 +653,10 @@ 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()) + verify( + trezorRepo, + never() + ).startWatcher(eq("usb1|nativeSegwit"), any(), any(), any(), anyOrNull(), any(), any()) watcherEvents.emit( "ble1|nativeSegwit" to WatcherEvent.TransactionsChanged( @@ -950,7 +956,7 @@ class HwWalletRepoTest : BaseUnitTest() { val signedTx = TrezorSignedTx( signatures = emptyList(), serializedTx = "rawtx", - txid = "signed-txid", + txid = null, ) val funding = HwFundingTransaction( psbt = "psbt", @@ -967,7 +973,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) assertEquals("rawtx", result.getOrThrow().serializedTx) - assertEquals("signed-txid", result.getOrThrow().txId) + assertEquals(null, result.getOrThrow().txId) assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) @@ -1117,7 +1123,6 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - @Test fun `scan delegates to trezorRepo`() = test { whenever(trezorRepo.scan(includeBluetooth = false)).thenReturn(Result.success(emptyList())) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index c53f588f10..f9e373987b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -240,6 +240,7 @@ class TransferViewModelTest : BaseUnitTest() { sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() + assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) verify(hwWalletRepo).composeFundingTransaction( eq(DEVICE_ID), eq(order.payment?.onchain?.address.orEmpty()), @@ -537,8 +538,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(TrezorException.UserCancelled())) whenever(hwWalletRepo.isKnownBluetoothDevice(DEVICE_ID)).thenReturn(false) - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_title)).thenReturn("reconnect title") - whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)).thenReturn("reconnect body") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_title) + ).thenReturn("reconnect title") + whenever( + context.getString(R.string.lightning__transfer_hw__reconnect_error_description) + ).thenReturn("reconnect body") sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle()