Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ fun Throwable.isTrezorUserCancellation(): Boolean =
it is TrezorException.PinCancelled ||
it is TrezorException.PassphraseCancelled
}

fun Throwable.isTrezorDeviceBusy(): Boolean =
generateSequence(this) { it.cause }.any { it is TrezorException.DeviceBusy }
112 changes: 82 additions & 30 deletions app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.runtime.Stable
import com.synonym.bitkitcore.AccountInfoResult
import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.CoinSelection
import com.synonym.bitkitcore.ComposeOutput
import com.synonym.bitkitcore.ComposeParams
Expand Down Expand Up @@ -54,6 +55,7 @@ import to.bitkit.data.HwWalletStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.isTrezorDeviceBusy
import to.bitkit.ext.isTrezorUserCancellation
import to.bitkit.ext.nowMs
import to.bitkit.ext.runSuspendCatching
Expand All @@ -72,6 +74,7 @@ import to.bitkit.services.TrezorUiHandler
import to.bitkit.services.TrezorWalletMode
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
Expand Down Expand Up @@ -105,6 +108,16 @@ class TrezorRepo @Inject constructor(
private val TRANSPORT_RESTORED_RECONNECT_DELAY = 2.seconds
private val CONNECT_ATTEMPT_POLL_INTERVAL = 250.milliseconds
private val CONNECT_ATTEMPT_MAX_WAIT = 28.seconds
private const val MAX_XPUB_FETCH_ATTEMPTS = 3
private val XPUB_FETCH_RETRY_DELAY = 300.milliseconds
private val TRANSIENT_FAILURE_MARKERS = listOf(
"TransportError",
"ConnectionError",
"DeviceDisconnected",
"Timeout",
"IoError",
"SessionError",
)
}

private val _state = MutableStateFlow(TrezorState())
Expand Down Expand Up @@ -271,7 +284,7 @@ class TrezorRepo @Inject constructor(
isSetup = CompletableDeferred()
}
Logger.error("Trezor init failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}
}
Expand All @@ -287,7 +300,7 @@ class TrezorRepo @Inject constructor(
devices
}.onFailure { e ->
Logger.error("Trezor scan failed", e, context = TAG)
_state.update { it.copy(isScanning = false, error = e.message) }
_state.update { it.copy(isScanning = false, error = trezorErrorMessage(e)) }
}
}

Expand All @@ -301,7 +314,7 @@ class TrezorRepo @Inject constructor(
devices
}.onFailure { e ->
Logger.error("Trezor listDevices failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -344,7 +357,7 @@ class TrezorRepo @Inject constructor(
features
}.onFailure { e ->
Logger.error("Trezor connect failed", e, context = TAG)
_state.update { it.copy(isConnecting = false, error = e.message) }
_state.update { it.copy(isConnecting = false, error = trezorErrorMessage(e)) }
}
}

Expand All @@ -366,7 +379,7 @@ class TrezorRepo @Inject constructor(
response
}.onFailure { e ->
Logger.error("Trezor getAddress failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand All @@ -386,7 +399,7 @@ class TrezorRepo @Inject constructor(
response
}.onFailure { e ->
Logger.error("Trezor getPublicKey failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand All @@ -405,7 +418,7 @@ class TrezorRepo @Inject constructor(
)
}.onFailure {
Logger.error("Failed to get Trezor transaction history", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand All @@ -424,7 +437,7 @@ class TrezorRepo @Inject constructor(
)
}.onFailure { e ->
Logger.error("Trezor getAccountInfo failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand All @@ -441,7 +454,7 @@ class TrezorRepo @Inject constructor(
)
}.onFailure { e ->
Logger.error("Trezor getAddressInfo failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -473,7 +486,7 @@ class TrezorRepo @Inject constructor(
trezorService.composeTransaction(params)
}.onFailure {
Logger.error("Trezor composeTransaction failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand All @@ -488,7 +501,7 @@ class TrezorRepo @Inject constructor(
response
}.onFailure {
Logger.error("Trezor signTxFromPsbt failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand All @@ -503,7 +516,7 @@ class TrezorRepo @Inject constructor(
)
}.onFailure {
Logger.error("Trezor broadcastRawTx failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand Down Expand Up @@ -531,7 +544,7 @@ class TrezorRepo @Inject constructor(
}.onFailure { e ->
TrezorDebugLog.log("DISCONNECT", "FAILED: ${e.message}")
Logger.error("Trezor disconnect failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand All @@ -551,7 +564,7 @@ class TrezorRepo @Inject constructor(
response
}.onFailure { e ->
Logger.error("Trezor signMessage failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand All @@ -573,7 +586,7 @@ class TrezorRepo @Inject constructor(
result
}.onFailure { e ->
Logger.error("Trezor verifyMessage failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -634,7 +647,7 @@ class TrezorRepo @Inject constructor(
_state.update { it.copy(isAutoReconnecting = false) }
}.onFailure { e ->
Logger.error("Auto-reconnect failed", e, context = TAG)
_state.update { it.copy(isAutoReconnecting = false, error = e.message) }
_state.update { it.copy(isAutoReconnecting = false, error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -680,7 +693,7 @@ class TrezorRepo @Inject constructor(
features
}.onFailure { e ->
Logger.error("Connect known device failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
if (!forceSession) {
disconnectStaleSession(deviceId)
}
Expand Down Expand Up @@ -825,7 +838,7 @@ class TrezorRepo @Inject constructor(
}.onFailure { e ->
TrezorDebugLog.log("FORGET", "FAILED: ${e.message}")
Logger.error("Forget device failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -854,7 +867,7 @@ class TrezorRepo @Inject constructor(
Logger.info("Started watcher '$watcherId'", context = TAG)
}.onFailure {
Logger.error("Start watcher failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand All @@ -866,7 +879,7 @@ class TrezorRepo @Inject constructor(
Logger.info("Stopped watcher '$watcherId'", context = TAG)
}.onFailure {
Logger.error("Stop watcher failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand All @@ -881,7 +894,7 @@ class TrezorRepo @Inject constructor(
TrezorDebugLog.log(WATCHER_TAG, "Stopped all watchers")
}.onFailure {
Logger.error("Stop all watchers failed", it, context = TAG)
_state.update { s -> s.copy(error = it.message) }
_state.update { s -> s.copy(error = trezorErrorMessage(it)) }
}
}

Expand Down Expand Up @@ -1012,17 +1025,39 @@ class TrezorRepo @Inject constructor(
private suspend fun fetchAccountXpubs(): Map<String, String> {
val coin = Env.network.toTrezorCoinType()
return ALL_ADDRESS_TYPES.mapNotNull { addressType ->
runCatching {
val xpub = trezorService.getPublicKey(
path = addressType.toAccountDerivationPath(network = Env.network),
fetchXpubForAddressType(addressType, coin)?.let { addressType.toSettingsString() to it }
}.toMap()
}

private suspend fun fetchXpubForAddressType(
addressType: AddressType,
coin: TrezorCoinType,
): String? {
val path = addressType.toAccountDerivationPath(network = Env.network)
val settingsKey = addressType.toSettingsString()
for (attempt in 1..MAX_XPUB_FETCH_ATTEMPTS) {
val result = runSuspendCatching {
trezorService.getPublicKey(
path = path,
coin = coin,
showOnTrezor = false,
).xpub
addressType.toSettingsString() to xpub
}.onFailure {
Logger.warn("Could not read xpub for '${addressType.toSettingsString()}'", it, context = TAG)
}.getOrNull()
}.toMap()
}
if (result.isSuccess) {
return result.getOrThrow()
}
val error = result.exceptionOrNull() ?: return null
Logger.warn(
"Could not read xpub for '$settingsKey' (attempt $attempt/$MAX_XPUB_FETCH_ATTEMPTS)",
error,
context = TAG,
)
if (!isTransientTransportFailure(error) || attempt == MAX_XPUB_FETCH_ATTEMPTS) {
return null
Comment thread
piotr-iohk marked this conversation as resolved.
Outdated
}
delay(XPUB_FETCH_RETRY_DELAY)
Comment thread
piotr-iohk marked this conversation as resolved.
}
return null
}

private suspend fun loadKnownDevices(): List<KnownDevice> = runCatching {
Expand Down Expand Up @@ -1081,7 +1116,7 @@ class TrezorRepo @Inject constructor(
_state.update { it.copy(error = null) }
}.onFailure { e ->
Logger.error("Trezor clearCredentials failed", e, context = TAG)
_state.update { it.copy(error = e.message) }
_state.update { it.copy(error = trezorErrorMessage(e)) }
}
}

Expand Down Expand Up @@ -1160,7 +1195,24 @@ class TrezorRepo @Inject constructor(
TrezorDebugLog.log("CRED", "$label: file=$sanitizedId.json exists=$exists size=$size")
}

private fun trezorErrorMessage(error: Throwable): String? =
if (error.isTrezorDeviceBusy()) {
TrezorErrorPresenter.userMessage(context, error)
} else {
error.message
}

private fun isTransientTransportFailure(error: Throwable): Boolean {
if (error.isTrezorDeviceBusy()) return true
val text = buildString {
append(error.message.orEmpty())
error.cause?.message?.let { append(it) }
}
return TRANSIENT_FAILURE_MARKERS.any { marker -> text.contains(marker, ignoreCase = true) }
Comment thread
piotr-iohk marked this conversation as resolved.
}

private fun isRetryableError(e: Throwable): Boolean {
if (e.isTrezorDeviceBusy()) return true
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
val msg = e.message?.lowercase() ?: return false
// A rejected session (wrong passphrase, or the user cancelling on-device
// passphrase entry) is a definitive failure, not a transient THP/transport
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import kotlinx.coroutines.launch
import to.bitkit.R
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
import kotlin.time.Duration.Companion.seconds

Expand Down Expand Up @@ -110,25 +112,29 @@ class HwConnectViewModel @Inject constructor(
}
}
}
.onFailure {
onConnectFailed(resolvedDeviceId, resolvedDeviceModel)
.onFailure { error ->
onConnectFailed(resolvedDeviceId, resolvedDeviceModel, error)
return@launch
}
}
hwWalletRepo.connect(resolvedDeviceId)
.onSuccess { onConnected(resolvedDeviceId, it) }
.onFailure { onConnectFailed(resolvedDeviceId, resolvedDeviceModel) }
.onFailure { error -> onConnectFailed(resolvedDeviceId, resolvedDeviceModel, error) }
connectJob = null
}
}

private fun onConnectFailed(deviceId: String, deviceModel: String) {
private fun onConnectFailed(deviceId: String, deviceModel: String, error: Throwable) {
_uiState.update {
it.copy(
isConnecting = false,
foundDeviceId = deviceId,
deviceModel = deviceModel,
errorMessage = context.getString(R.string.hardware__connect_error),
errorMessage = if (error.isTrezorDeviceBusy()) {
TrezorErrorPresenter.userMessage(context, error)
} else {
context.getString(R.string.hardware__connect_error)
},
)
}
setEffect(
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/utils/TrezorErrorPresenter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package to.bitkit.utils

import android.content.Context
import to.bitkit.R
import to.bitkit.ext.isTrezorDeviceBusy

object TrezorErrorPresenter {
fun userMessage(
context: Context,
error: Throwable,
fallback: String = context.getString(R.string.hardware__connect_error),
): String {
if (error.isTrezorDeviceBusy()) {
return context.getString(R.string.hardware__device_busy)
}
return error.message?.takeIf { it.isNotBlank() } ?: fallback
}
}
Loading
Loading