Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
}
8 changes: 8 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,14 @@ data class HwFundingTransaction(
val satsPerVByte: ULong,
)

data class HwFundingSignedTx(
val serializedTx: String,
val miningFeeSats: ULong,
val feeRate: ULong,
val totalSpent: ULong,
val txId: String? = null,
)

data class HwFundingBroadcastResult(
val txId: String,
val miningFeeSats: ULong,
Expand Down
50 changes: 44 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 @@ -87,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)
Expand Down Expand Up @@ -128,6 +139,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 +233,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 +251,40 @@ 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,
txId = signedTx.txid,
)
}
}

/** 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).getOrElse { error ->
signedTx.txId?.takeIf { error.isAlreadyBroadcastError() } ?: throw error
}
HwFundingBroadcastResult(
txId = txId,
miningFeeSats = signedTx.miningFeeSats,
feeRate = signedTx.feeRate,
totalSpent = signedTx.totalSpent,
)
}
}

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<Unit> = trezorRepo.disconnectStaleSession(deviceId)

/**
Expand Down
46 changes: 36 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,37 @@ 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 { refreshLockedFeatures(deviceId, 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 suspend fun refreshLockedFeatures(
deviceId: String,
features: TrezorFeatures,
): Result<TrezorFeatures> {
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<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 +904,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 +916,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 +1260,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
23 changes: 21 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,28 @@ 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 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<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 = sanitize(msg)
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,9 +37,20 @@ object TrezorDebugLog {
updated.toImmutableList()
}
}
Logger.debug("Recorded Trezor diagnostic '$tag': '$sanitizedMessage'", context = TAG)
Comment thread
piotr-iohk marked this conversation as resolved.
}

fun clear() {
_lines.update { persistentListOf() }
}

private fun sanitize(message: String): String {
val quotedRedacted = quotedSecretValuePattern.replace(message) {
"${it.groupValues[1]}<redacted>"
}
val multiWordRedacted = multiWordSecretValuePattern.replace(quotedRedacted) {
"${it.groupValues[1]}=<redacted>"
}
return secretValuePattern.replace(multiWordRedacted) { "${it.groupValues[1]}=<redacted>" }
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/services/TrezorService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading