Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion .cursor/rules/rules.main.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 25 additions & 6 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,6 +129,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()
Expand Down Expand Up @@ -219,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<HwFundingBroadcastResult> = withContext(ioDispatcher) {
): Result<HwFundingSignedTx> = withContext(ioDispatcher) {
runSuspendCatching {
val signed = runSuspendCatching {
trezorRepo.signTxFromPsbt(
Expand All @@ -237,16 +241,31 @@ 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,
Comment thread
piotr-iohk marked this conversation as resolved.
miningFeeSats = funding.miningFeeSats,
feeRate = ceil(funding.feeRate.toDouble()).toULong(),
totalSpent = funding.totalSpent,
)
}
}

/** Broadcasts a signed funding payment without requiring the hardware device. */
suspend fun broadcastFunding(
signedTx: HwFundingSignedTx,
): Result<HwFundingBroadcastResult> = 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<Unit> = trezorRepo.disconnectStaleSession(deviceId)

/**
Expand Down
36 changes: 26 additions & 10 deletions app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,16 +51,19 @@ 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
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
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
Expand All @@ -77,7 +79,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
Expand Down Expand Up @@ -152,6 +153,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.
*/
Expand Down Expand Up @@ -716,13 +720,27 @@ class TrezorRepo @Inject constructor(
}

suspend fun ensureConnected(deviceId: String): Result<TrezorFeatures> = 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()
Comment thread
piotr-iohk marked this conversation as resolved.
}

private fun Result<TrezorFeatures>.requireUnlocked(): Result<TrezorFeatures> = 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
Expand Down Expand Up @@ -876,8 +894,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)) }
Expand All @@ -889,7 +906,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)) }
Expand Down Expand Up @@ -1234,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
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/to/bitkit/services/TrezorDebugLog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImmutableList<String>>(persistentListOf())
val lines: StateFlow<ImmutableList<String>> = _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]}=<redacted>" }
val ts = synchronized(fmt) { fmt.format(Date()) }
val line = "$ts [$tag] $sanitizedMessage"
_lines.update { current ->
val updated = current + line
if (updated.size > MAX_LINES) {
Expand All @@ -29,6 +36,7 @@ object TrezorDebugLog {
updated.toImmutableList()
}
}
Logger.debug("Recorded Trezor diagnostic '$tag': '$sanitizedMessage'", context = TAG)
Comment thread
piotr-iohk marked this conversation as resolved.
}

fun clear() {
Expand Down
Loading
Loading