From 0eb12678ecb4a4eee0878e89b90e6cc023fb8560 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:39 +0200 Subject: [PATCH 01/29] feat: add hw funding account lookup --- .../to/bitkit/repositories/HwWalletRepo.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 20df16bf9..6992f4568 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -1,6 +1,10 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType @@ -36,6 +40,9 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.DEFAULT_ADDRESS_TYPE +import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.TransportType @@ -43,6 +50,7 @@ import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toTrezorCoinType import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -59,6 +67,7 @@ import kotlin.time.ExperimentalTime * Built on top of [TrezorRepo], which owns the device list, connect orchestration * and the underlying watcher transport. */ +@Suppress("TooManyFunctions") @OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( @@ -132,6 +141,70 @@ class HwWalletRepo @Inject constructor( return trezorRepo.connect(deviceId) } + /** Reconnects a known paired device so its session is live for on-device signing. */ + suspend fun reconnect(deviceId: String): Result = trezorRepo.connectKnownDevice(deviceId) + + /** + * Resolves the native-segwit funding account for a paired wallet: its account xpub, [AccountType] + * and the balance currently watched for that account. Used to compose the on-chain funding send the + * Trezor signs when transferring to spending. v1 funds from native segwit only. + */ + suspend fun getFundingAccount(deviceId: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + val devices = hwWalletStore.loadKnownDevices() + val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } + val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + val xpub = requireNotNull(target.xpubs[DEFAULT_ADDRESS_TYPE_STRING]) { + "Hardware wallet '$deviceId' has no native-segwit account" + } + val balanceSats = _watcherData.value + .filterKeys { key -> + key.substringAfter(WATCHER_ID_SEPARATOR) == DEFAULT_ADDRESS_TYPE_STRING && + key.toDeviceId() in groupIds + } + .values.fold(0uL) { acc, watcher -> acc + watcher.balanceSats } + HwFundingAccount( + xpub = xpub, + accountType = DEFAULT_ADDRESS_TYPE.toAccountType(), + balanceSats = balanceSats, + ) + } + } + + /** + * Composes the on-chain funding payment from the device's native-segwit account, has the Trezor sign + * it and broadcasts it. The signing step prompts the user on the device. Returns the broadcast txid. + */ + suspend fun signAndBroadcastFunding( + deviceId: String, + address: String, + sats: ULong, + satsPerVByte: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val account = getFundingAccount(deviceId).getOrThrow() + val network = Env.network.toCoreNetwork() + val composed = trezorRepo.composeTransaction( + extendedKey = account.xpub, + outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), + feeRates = listOf(satsPerVByte.toFloat()), + network = network, + accountType = account.accountType, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ).getOrThrow() + val success = composed.filterIsInstance().firstOrNull() + ?: throw AppError( + composed.filterIsInstance().firstOrNull()?.error + ?: "Failed to compose hardware transfer" + ) + val signed = trezorRepo.signTxFromPsbt( + psbtBase64 = success.psbt, + network = Env.network.toTrezorCoinType(), + ).getOrThrow() + trezorRepo.broadcastRawTx(serializedTx = signed.serializedTx, network = network).getOrThrow() + } + } + /** * Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the * same wallet identity so the same device paired over both transports renames consistently. @@ -428,6 +501,13 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } +/** Native-segwit account used to fund a Trezor-signed transfer to spending. */ +data class HwFundingAccount( + val xpub: String, + val accountType: AccountType, + val balanceSats: ULong, +) + private data class WatcherSettings( val monitoredTypes: Set, val electrumUrl: String, From 42ce34bb9721a8a76745b7e51ad08b96e012a617 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:49 +0200 Subject: [PATCH 02/29] feat: fund spending from hardware wallet --- .../transfer/SpendingAdvancedScreen.kt | 1 + .../screens/transfer/SpendingAmountScreen.kt | 1 + .../to/bitkit/viewmodels/TransferViewModel.kt | 117 ++++++++++++++++-- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 06a48a341..9f6493966 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -93,6 +93,7 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() + TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 36ea358a0..984043d34 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -83,6 +83,7 @@ fun SpendingAmountScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> onOrderCreated() + TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastError -> toast(effect.title, effect.description) is TransferEffect.ToastException -> toastException(effect.e) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 202ac4ee9..008f2aab2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -34,6 +34,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -57,6 +58,7 @@ class TransferViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, + private val hwWalletRepo: HwWalletRepo, private val walletRepo: WalletRepo, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, @@ -226,22 +228,25 @@ class TransferViewModel @Inject constructor( channelId = order.channel?.shortChannelId, isMaxAmount = shouldUseSendAll, ) - .onSuccess { txId -> - cacheStore.addPaidOrder(orderId = order.id, txId = txId) - transferRepo.createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - lspOrderId = order.id, - ) - launch { walletRepo.syncBalances() } - launch { watchOrder(order.id) } - } + .onSuccess { txId -> fundPaidOrder(order, txId) } .onFailure { error -> ToastEventBus.send(error) } } } + /** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */ + private suspend fun fundPaidOrder(order: IBtOrder, txId: String) { + cacheStore.addPaidOrder(orderId = order.id, txId = txId) + transferRepo.createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + lspOrderId = order.id, + ) + viewModelScope.launch { walletRepo.syncBalances() } + viewModelScope.launch { watchOrder(order.id) } + } + private suspend fun watchOrder(orderId: String): Result = runCatching { Logger.debug("Started watching order: '$orderId'", context = TAG) @@ -434,6 +439,93 @@ class TransferViewModel @Inject constructor( // endregion + // region Spending HW (Trezor watch-only transfer to spending) + + /** + * Computes AVAILABLE/MAX for a Trezor transfer from the device's native-segwit account balance, + * reserving an on-chain fee for the funding send the device signs. Reuses the spending limit math. + */ + fun updateHwLimits(deviceId: String) { + viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } + + val account = hwWalletRepo.getFundingAccount(deviceId).getOrElse { + Logger.error("Failed to load hardware funding account", it, context = TAG) + _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } + setTransferEffect(TransferEffect.ToastException(it)) + return@launch + } + + awaitNodeRunning() + updateTransferValues(0uL) + + val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve().safe() + + val initialLspFees = estimateInitialLspFees(availableAmount) + if (initialLspFees == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() + estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) + } + } + + /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ + fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { + viewModelScope.launch { + _spendingUiState.update { it.copy(isSigning = true) } + + val address = order.payment?.onchain?.address.orEmpty() + if (address.isEmpty()) { + _spendingUiState.update { it.copy(isSigning = false) } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) + return@launch + } + + if (!isHwDeviceConnected(deviceId)) { + hwWalletRepo.reconnect(deviceId).onFailure { + Logger.error("Failed to reconnect hardware device", it, context = TAG) + _spendingUiState.update { s -> s.copy(isSigning = false) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), + description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description), + ) + return@launch + } + } + + hwWalletRepo.signAndBroadcastFunding( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = hwFundingSatsPerVByte(), + ).onSuccess { txId -> + fundPaidOrder(order, txId) + _spendingUiState.update { it.copy(isSigning = false) } + setTransferEffect(TransferEffect.OnHwTxSigned) + }.onFailure { e -> + Logger.error("Hardware transfer failed", e, context = TAG) + _spendingUiState.update { it.copy(isSigning = false) } + ToastEventBus.send(e) + } + } + } + + private fun isHwDeviceConnected(deviceId: String): Boolean = + hwWalletRepo.wallets.value.any { deviceId in it.deviceIds && it.isConnected } + + private suspend fun hwFundingFeeReserve(): ULong = hwFundingSatsPerVByte().safe() * HW_FUNDING_TX_VBYTES.safe() + + private suspend fun hwFundingSatsPerVByte(): ULong { + val speed = settingsStore.data.first().defaultTransactionSpeed + return lightningRepo.getFeeRateForSpeed(speed).getOrNull() ?: 0uL + } + + // endregion + // region Balance Calc fun updateTransferValues(clientBalanceSat: ULong) { @@ -638,6 +730,9 @@ class TransferViewModel @Inject constructor( private const val MIN_STEP_DELAY_MS = 500L private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 + + /** Flat vbyte reserve for the funding tx; exact fee computed by the Trezor at sign time. */ + private const val HW_FUNDING_TX_VBYTES = 200uL const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -654,6 +749,7 @@ data class TransferToSpendingUiState( val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, val isLoading: Boolean = false, + val isSigning: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) @@ -667,6 +763,7 @@ data class TransferValues( sealed interface TransferEffect { data object OnOrderCreated : TransferEffect + data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } From 130a25e589102c1458a57a5c9a3afdeef7bfdcef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:49 +0200 Subject: [PATCH 03/29] feat: add spending amount hw screen --- .../transfer/SpendingAmountHwScreen.kt | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt new file mode 100644 index 000000000..2e626d0ed --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt @@ -0,0 +1,291 @@ +package to.bitkit.ui.screens.transfer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.ConnectionIssuesView +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadTextField +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SyncNodeView +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputEffect +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.TransferEffect +import to.bitkit.viewmodels.TransferToSpendingUiState +import to.bitkit.viewmodels.TransferViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel + +@Suppress("ViewModelForwarding") +@Composable +fun SpendingAmountHwScreen( + deviceId: String, + viewModel: TransferViewModel, + isOffline: Boolean, + onBackClick: () -> Unit = {}, + onOrderCreated: () -> Unit = {}, + toastException: (Throwable) -> Unit, + toast: (title: String, description: String) -> Unit, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), +) { + val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() + val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) + val currentCurrencies by rememberUpdatedState(currencies) + + LaunchedEffect(deviceId, isOffline) { + viewModel.updateHwLimits(deviceId) + } + + LaunchedEffect(Unit) { + viewModel.transferEffects.collect { effect -> + when (effect) { + TransferEffect.OnOrderCreated -> onOrderCreated() + TransferEffect.OnHwTxSigned -> Unit + is TransferEffect.ToastError -> toast(effect.title, effect.description) + is TransferEffect.ToastException -> toastException(effect.e) + } + } + } + + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> { + amountInputViewModel.setSats(currentMaxAllowedToSend, currentCurrencies) + toast( + context.getString(R.string.lightning__spending_amount__error_max__title), + context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), + ) + } + } + } + } + + Box { + Content( + isNodeRunning = isNodeRunning, + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onBackClick = onBackClick, + onClickQuarter = { + amountInputViewModel.setSats(uiState.quarterAmount, currencies) + }, + onClickMaxAmount = { + amountInputViewModel.setSats(uiState.maxAllowedToSend, currencies) + }, + onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + modifier = Modifier.statusBarsPadding() + ) + } + } +} + +@Suppress("ViewModelForwarding") +@Composable +private fun Content( + isNodeRunning: Boolean, + uiState: TransferToSpendingUiState, + amountInputViewModel: AmountInputViewModel, + onBackClick: () -> Unit, + onClickQuarter: () -> Unit, + onClickMaxAmount: () -> Unit, + onConfirmAmount: () -> Unit, + currencies: CurrencyState = LocalCurrencies.current, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + + if (isNodeRunning) { + NodeRunning( + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onClickQuarter = onClickQuarter, + onClickMaxAmount = onClickMaxAmount, + onConfirmAmount = onConfirmAmount, + ) + } else { + SyncNodeView( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + } +} + +@Suppress("ViewModelForwarding") +@Composable +private fun NodeRunning( + uiState: TransferToSpendingUiState, + amountInputViewModel: AmountInputViewModel, + currencies: CurrencyState, + onClickQuarter: () -> Unit, + onClickMaxAmount: () -> Unit, + onConfirmAmount: () -> Unit, +) { + LaunchedEffect(uiState.maxAllowedToSend) { + amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferAmount") + ) { + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + + VerticalSpacer(minHeight = 16.dp, maxHeight = 32.dp) + + Display( + text = stringResource(R.string.lightning__spending_amount__title) + .withAccent(accentColor = Colors.Purple) + ) + + FillHeight() + + NumberPadTextField( + viewModel = amountInputViewModel, + currencies = currencies, + showSecondaryField = false, + modifier = Modifier + .fillMaxWidth() + .testTag("HardwareTransferAmountNumberField") + ) + + FillHeight() + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + .testTag("HardwareTransferAmountNumberPad") + ) { + Column { + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + modifier = Modifier.testTag("HardwareTransferAmountAvailable") + ) + VerticalSpacer(8.dp) + MoneySSB(sats = uiState.balanceAfterFee, modifier = Modifier.testTag("HardwareTransferAmountUnit")) + } + FillWidth() + UnitButton( + color = Colors.Purple, + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("HardwareTransferAmountUnitButton") + ) + NumberPadActionButton( + text = stringResource(R.string.lightning__spending_amount__quarter), + color = Colors.Purple, + onClick = onClickQuarter, + modifier = Modifier.testTag("HardwareTransferAmountQuarter") + ) + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Purple, + onClick = onClickMaxAmount, + modifier = Modifier.testTag("HardwareTransferAmountMax") + ) + } + + HorizontalDivider() + VerticalSpacer(16.dp) + + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + enabled = !uiState.isLoading, + ) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onConfirmAmount, + enabled = !uiState.isLoading && amountUiState.sats <= uiState.maxAllowedToSend, + isLoading = uiState.isLoading, + modifier = Modifier.testTag("HardwareTransferAmountContinue") + ) + + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, device = "id:pixel_9_pro_xl", name = "Large") +@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") +@Composable +private fun Preview() { + AppThemeSurface { + Content( + isNodeRunning = true, + uiState = TransferToSpendingUiState(maxAllowedToSend = 158_234, balanceAfterFee = 158_234), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), + onBackClick = {}, + onClickQuarter = {}, + onClickMaxAmount = {}, + onConfirmAmount = {}, + ) + } +} From 06c602faec75dac18af3fc450fdb02478a951754 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:49 +0200 Subject: [PATCH 04/29] feat: add hw sign and signed screens --- .../screens/transfer/SpendingHwSignScreen.kt | 209 ++++++++++++++++++ .../transfer/SpendingHwSignedScreen.kt | 108 +++++++++ .../screens/transfer/TransferPreviewData.kt | 66 ++++++ 3 files changed, 383 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt new file mode 100644 index 000000000..aa0fdee14 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt @@ -0,0 +1,209 @@ +package to.bitkit.ui.screens.transfer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.IBtOrder +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferEffect +import to.bitkit.viewmodels.TransferViewModel + +@Composable +fun SpendingHwSignScreen( + deviceId: String, + viewModel: TransferViewModel, + onBackClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onAdvancedClick: () -> Unit = {}, + onSigned: () -> Unit = {}, +) { + val state by viewModel.spendingUiState.collectAsStateWithLifecycle() + + val order = state.order ?: run { + onCloseClick() + return + } + + LaunchedEffect(Unit) { + viewModel.transferEffects.collect { effect -> + if (effect is TransferEffect.OnHwTxSigned) onSigned() + } + } + + Content( + order = order, + isAdvanced = state.isAdvanced, + isSigning = state.isSigning, + onBackClick = onBackClick, + onLearnMoreClick = onLearnMoreClick, + onAdvancedClick = onAdvancedClick, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, deviceId) }, + ) +} + +@Composable +private fun Content( + order: IBtOrder, + isAdvanced: Boolean, + isSigning: Boolean, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onAdvancedClick: () -> Unit, + onUseDefaultLspBalanceClick: () -> Unit, + onOpenConnect: () -> Unit, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.trezor), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp) + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferSign") + ) { + VerticalSpacer(32.dp) + Display( + stringResource(R.string.lightning__transfer_hw__sign_title) + .withAccent(accentColor = Colors.Purple) + ) + VerticalSpacer(16.dp) + + SpendingHwFeeGrid(order = order) + + VerticalSpacer(24.dp) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("HardwareTransferSignLearnMore") + ) + PrimaryButton( + text = stringResource( + if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced + ), + size = ButtonSize.Small, + fullWidth = false, + onClick = { if (isAdvanced) onUseDefaultLspBalanceClick() else onAdvancedClick() }, + modifier = Modifier.testTag( + if (isAdvanced) "HardwareTransferSignDefault" else "HardwareTransferSignAdvanced" + ) + ) + } + + FillHeight() + + PrimaryButton( + text = stringResource(R.string.lightning__transfer_hw__open_connect), + onClick = onOpenConnect, + enabled = !isSigning, + isLoading = isSigning, + modifier = Modifier.testTag("HardwareTransferOpenTrezorConnect") + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Composable +internal fun SpendingHwFeeGrid( + order: IBtOrder, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__network_fee), + amount = order.networkFeeSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + amount = order.serviceFeeSat.toLong(), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__amount), + amount = order.clientBalanceSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__total), + amount = order.feeSat.toLong(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + order = previewBtOrder(), + isAdvanced = false, + isSigning = false, + onBackClick = {}, + onLearnMoreClick = {}, + onAdvancedClick = {}, + onUseDefaultLspBalanceClick = {}, + onOpenConnect = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt new file mode 100644 index 000000000..baaed2c0b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt @@ -0,0 +1,108 @@ +package to.bitkit.ui.screens.transfer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.IBtOrder +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferViewModel + +/** Dwell on the signed confirmation before forwarding, matching the transfer flow's checkmark beat. */ +private const val SIGNED_AUTO_NAV_DELAY_MS = 2500L + +@Composable +fun SpendingHwSignedScreen( + viewModel: TransferViewModel, + onContinue: () -> Unit = {}, + onCloseClick: () -> Unit = {}, +) { + val state by viewModel.spendingUiState.collectAsStateWithLifecycle() + + val order = state.order ?: run { + onCloseClick() + return + } + + LaunchedEffect(Unit) { + delay(SIGNED_AUTO_NAV_DELAY_MS) + onContinue() + } + + Content(order = order, onBackClick = onCloseClick) +} + +@Composable +private fun Content( + order: IBtOrder, + onBackClick: () -> Unit, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.check), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.Center) + .padding(top = 120.dp) + .size(220.dp) + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferSigned") + ) { + VerticalSpacer(32.dp) + Display( + stringResource(R.string.lightning__transfer_hw__signed_title) + .withAccent(accentColor = Colors.Purple) + ) + VerticalSpacer(16.dp) + + SpendingHwFeeGrid(order = order) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + order = previewBtOrder(), + onBackClick = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt new file mode 100644 index 000000000..257519fe9 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt @@ -0,0 +1,66 @@ +package to.bitkit.ui.screens.transfer + +import com.synonym.bitkitcore.BtBolt11InvoiceState +import com.synonym.bitkitcore.BtOrderState +import com.synonym.bitkitcore.BtOrderState2 +import com.synonym.bitkitcore.BtPaymentState +import com.synonym.bitkitcore.BtPaymentState2 +import com.synonym.bitkitcore.IBtBolt11Invoice +import com.synonym.bitkitcore.IBtOnchainTransactions +import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.IBtPayment +import com.synonym.bitkitcore.ILspNode + +internal fun previewBtOrder( + networkFeeSat: ULong = 2_483UL, + serviceFeeSat: ULong = 1_520UL, + clientBalanceSat: ULong = 967_724UL, + feeSat: ULong = 971_727UL, +): IBtOrder = IBtOrder( + id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", + state = BtOrderState.CREATED, + state2 = BtOrderState2.CREATED, + feeSat = feeSat, + networkFeeSat = networkFeeSat, + serviceFeeSat = serviceFeeSat, + lspBalanceSat = 2_000_000UL, + clientBalanceSat = clientBalanceSat, + zeroConf = false, + zeroReserve = true, + clientNodeId = null, + channelExpiryWeeks = 8u, + channelExpiresAt = "2025-09-22T08:29:03Z", + orderExpiresAt = "2025-07-29T08:29:03Z", + channel = null, + lspNode = ILspNode( + alias = "Bitkit LSP", + pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", + connectionStrings = listOf("mock@127.0.0.1:9735"), + readonly = null, + ), + lnurl = null, + payment = IBtPayment( + state = BtPaymentState.CREATED, + state2 = BtPaymentState2.CREATED, + paidSat = 0UL, + bolt11Invoice = IBtBolt11Invoice( + request = "lnmock", + state = BtBolt11InvoiceState.PENDING, + expiresAt = "2025-07-28T12:00:00Z", + updatedAt = "2025-07-28T08:30:00Z", + ), + onchain = IBtOnchainTransactions( + address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + confirmedSat = 0UL, + requiredConfirmations = 1u, + transactions = emptyList(), + ), + isManuallyPaid = null, + manualRefunds = null, + ), + couponCode = null, + source = null, + discount = null, + updatedAt = "2025-07-28T08:29:03Z", + createdAt = "2025-07-28T08:29:03Z", +) From be20234a2ff000ad68ef0641ecb738277251884b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:50 +0200 Subject: [PATCH 05/29] feat: wire hw transfer flow --- app/src/main/java/to/bitkit/ui/ContentView.kt | 67 +++++++++++++++---- .../screens/wallets/HardwareWalletScreen.kt | 2 +- app/src/main/res/values/strings.xml | 5 ++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 515e011b6..3b3b8ccf6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -118,8 +118,11 @@ import to.bitkit.ui.screens.transfer.SavingsIntroScreen import to.bitkit.ui.screens.transfer.SavingsProgressScreen import to.bitkit.ui.screens.transfer.SettingUpScreen import to.bitkit.ui.screens.transfer.SpendingAdvancedScreen +import to.bitkit.ui.screens.transfer.SpendingAmountHwScreen import to.bitkit.ui.screens.transfer.SpendingAmountScreen import to.bitkit.ui.screens.transfer.SpendingConfirmScreen +import to.bitkit.ui.screens.transfer.SpendingHwSignScreen +import to.bitkit.ui.screens.transfer.SpendingHwSignedScreen import to.bitkit.ui.screens.transfer.SpendingIntroScreen import to.bitkit.ui.screens.transfer.TransferIntroScreen import to.bitkit.ui.screens.transfer.external.ExternalAmountScreen @@ -179,7 +182,6 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute @@ -770,6 +772,44 @@ private fun RootNavHost( }, ) } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + SpendingAmountHwScreen( + deviceId = deviceId, + viewModel = transferViewModel, + isOffline = connectivityState != ConnectivityState.CONNECTED, + onBackClick = { navController.popBackStack() }, + onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(deviceId)) }, + toastException = { appViewModel.toast(it) }, + toast = { title, description -> + appViewModel.toast( + type = Toast.ToastType.ERROR, + title = title, + description = description, + ) + }, + ) + } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + SpendingHwSignScreen( + deviceId = deviceId, + viewModel = transferViewModel, + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.navigateToHome() }, + onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, + onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, + onSigned = { navController.navigateTo(Routes.SpendingHwSigned) }, + ) + } + composableWithDefaultTransitions { + SpendingHwSignedScreen( + viewModel = transferViewModel, + onContinue = { navController.navigateTo(Routes.SettingUp) }, + onCloseClick = { navController.navigateToHome() }, + ) + } composableWithDefaultTransitions { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingConfirmScreen( @@ -786,7 +826,8 @@ private fun RootNavHost( SpendingAdvancedScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - onOrderCreated = { navController.popBackStack(inclusive = false) }, + // Pops back to whoever opened Advanced: SpendingConfirm or SpendingHwSign. + onOrderCreated = { navController.popBackStack() }, ) } composableWithDefaultTransitions { @@ -998,18 +1039,11 @@ private fun NavGraphBuilder.home( ) } composableWithDefaultTransitions { - val scope = rememberCoroutineScope() + val deviceId = it.toRoute().deviceId HardwareWalletScreen( - deviceId = it.toRoute().deviceId, + deviceId = deviceId, onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, - onTransferToSpendingClick = { - scope.launch { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Transfer to spending not yet implemented.", - ) - } - }, + onTransferToSpendingClick = { navController.navigateTo(Routes.SpendingAmountHw(deviceId)) }, onBackClick = { navController.popBackStack() }, ) } @@ -1964,6 +1998,15 @@ sealed interface Routes { @Serializable data object SpendingAmount : Routes + @Serializable + data class SpendingAmountHw(val deviceId: String) : Routes + + @Serializable + data class SpendingHwSign(val deviceId: String) : Routes + + @Serializable + data object SpendingHwSigned : Routes + @Serializable data object SpendingConfirm : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index c45d40ced..3a1a67437 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -182,7 +182,7 @@ private fun HardwareWalletContent( ) }, hazeState = hazeState, - modifier = Modifier.testTag("HwTransferToSpending") + modifier = Modifier.testTag("HardwareTransferToSpending") ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9df11ac3a..281340411 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -364,6 +364,11 @@ Custom <accent>fee</accent> Transfer Funds Swipe To Transfer + Open Trezor Connect + Could not reach your Trezor. Reconnect the device and try again. + Reconnect Hardware Device + Sign with\n<accent>your device</accent> + Transaction\n<accent>signed</accent> TRANSFER IN PROGRESS TRANSFER READY IN %s Get Started From 8ddcc39778b9fcae4c600cbb02aac3eb11d6dc86 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:50 +0200 Subject: [PATCH 06/29] test: cover hw transfer view model --- .../viewmodels/TransferViewModelTest.kt | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index e4472bdab..da084a553 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1,10 +1,13 @@ package to.bitkit.viewmodels import android.content.Context +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -12,21 +15,28 @@ 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.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState +import to.bitkit.models.HwWallet +import to.bitkit.models.TransportType import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.HwFundingAccount +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.previewBtOrder import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.time.Clock @@ -39,6 +49,7 @@ class TransferViewModelTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() private val blocktankRepo = mock() + private val hwWalletRepo = mock() private val walletRepo = mock() private val settingsStore = mock() private val cacheStore = mock() @@ -66,6 +77,7 @@ class TransferViewModelTest : BaseUnitTest() { context = context, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + hwWalletRepo = hwWalletRepo, walletRepo = walletRepo, settingsStore = settingsStore, cacheStore = cacheStore, @@ -126,6 +138,69 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) } + @Test + fun `updateHwLimits sources the available amount from the hardware account balance`() = test { + // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + .thenReturn(Result.success(HwFundingAccount(XPUB, AccountType.NATIVE_SEGWIT, ON_CHAIN_BALANCE))) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateHwLimits(DEVICE_ID) + advanceUntilIdle() + + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) + } + + @Test + fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any(), any(), any())).thenReturn(Result.success(TXID)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).signAndBroadcastFunding( + eq(DEVICE_ID), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(FEE_RATE), + ) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + verify(hwWalletRepo, never()).reconnect(any()) + } + + @Test + fun `onTransferToSpendingHwConfirm reconnects a disconnected device and aborts when it fails`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.reconnect(DEVICE_ID)).thenReturn(Result.failure(RuntimeException("no device"))) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).reconnect(DEVICE_ID) + verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any(), any(), any()) + } + + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( + id = deviceId, + name = "Trezor", + model = "Safe 3", + transportType = TransportType.USB, + isConnected = connected, + balanceSats = 0uL, + activities = persistentListOf(), + deviceIds = persistentSetOf(deviceId), + ) + private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, @@ -147,5 +222,9 @@ class TransferViewModelTest : BaseUnitTest() { const val NETWORK_FEE = 2_112uL const val SERVICE_FEE = 286uL const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE + const val DEVICE_ID = "dev1" + const val XPUB = "zpub-test" + const val TXID = "tx-abc" + const val FEE_RATE = 2uL } } From 3c4dcdfeb60e3ff136ecc26984001a5f4bb804ce Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:50 +0200 Subject: [PATCH 07/29] test: add hw transfer journey --- journeys/hardware-wallet/detail-overview.xml | 6 +-- .../hardware-wallet/transfer-to-spending.xml | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 journeys/hardware-wallet/transfer-to-spending.xml diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index e8175b31c..8d13bda3c 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -1,8 +1,8 @@ Opens the hardware wallet detail screen from the home tile and verifies its overview: - the top bar, balance header, the current Transfer To Spending placeholder when funds - are present, the grouped activity list, and the Remove device confirm dialog. The final + the top bar, balance header, the Transfer To Spending entry when funds are present, + the grouped activity list, and the Remove device confirm dialog. The final Remove step forgets the device, so run this last (re-run connect-home-tile.xml to pair again). Requires a paired Bridge emulator (run connect-home-tile.xml first). @@ -17,7 +17,7 @@ Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown (testTag "HwTransferToSpending"), tap it and verify a notice appears saying "Transfer to spending not yet implemented.", otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it, verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml new file mode 100644 index 000000000..4c628a7b4 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,43 @@ + + + Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With + Your Device -> Transaction Signed -> Processing Payment. The funding send is signed on the + Bridge emulator device, so no physical Trezor is required. Requires a paired Bridge emulator + (run connect-home-tile.xml first) whose native-segwit account holds spendable regtest funds, + and the bitkit-docker stack (Blocktank + regtest) running so the channel order can be created + and funded. Mirrors the on-chain Transfer To Spending flow, swapping local signing for the + device. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad + + + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") to set a valid amount below the available limit + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + + + Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and wait while the Bridge emulator composes, signs and broadcasts the funding transaction + + + Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + + + Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears + + + From 7c1e1380e85004852fce66b3a587d55ab804a133 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 19:35:50 +0200 Subject: [PATCH 08/29] chore: add hw transfer changelog --- changelog.d/next/1028.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1028.added.md diff --git a/changelog.d/next/1028.added.md b/changelog.d/next/1028.added.md new file mode 100644 index 000000000..4d28eedb4 --- /dev/null +++ b/changelog.d/next/1028.added.md @@ -0,0 +1 @@ +Transfer funds from a paired Trezor hardware wallet to your spending balance, with the funding transaction signed on the device. From 79c970a5d55fda006be1d7c85b774c185332aeb5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 22 Jun 2026 21:23:51 +0200 Subject: [PATCH 09/29] chore: rename changelog fragment --- changelog.d/next/{1028.added.md => 1039.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1028.added.md => 1039.added.md} (100%) diff --git a/changelog.d/next/1028.added.md b/changelog.d/next/1039.added.md similarity index 100% rename from changelog.d/next/1028.added.md rename to changelog.d/next/1039.added.md From 455eb684a8ff2fb8f3d7047afc97e95f8784ca0e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 00:24:17 +0200 Subject: [PATCH 10/29] fix: track pending hw transfers --- .../java/to/bitkit/data/dao/TransferDao.kt | 4 ++ .../to/bitkit/repositories/TransferRepo.kt | 35 +++++++++++++++ .../usecases/DeriveBalanceStateUseCase.kt | 38 +++++++++++----- .../viewmodels/ActivityDetailViewModel.kt | 12 ++++- .../to/bitkit/viewmodels/TransferViewModel.kt | 27 ++++++++++-- .../ActivityDetailViewModelTest.kt | 21 +++++++++ .../usecases/DeriveBalanceStateUseCaseTest.kt | 44 +++++++++++++++++++ .../viewmodels/TransferViewModelTest.kt | 16 +++++++ 8 files changed, 182 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt index 72f630625..2f67f711e 100644 --- a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import to.bitkit.data.entities.TransferEntity @Dao +@Suppress("TooManyFunctions") interface TransferDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transfer: TransferEntity) @@ -35,6 +36,9 @@ interface TransferDao { @Query("SELECT * FROM transfers WHERE id = :id LIMIT 1") suspend fun getById(id: String): TransferEntity? + @Query("SELECT * FROM transfers WHERE fundingTxId = :fundingTxId LIMIT 1") + suspend fun getByFundingTxId(fundingTxId: String): TransferEntity? + @Query("UPDATE transfers SET isSettled = 1, settledAt = :settledAt WHERE id = :id") suspend fun markSettled(id: String, settledAt: Long) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index aff48fec8..3427ae7e1 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 +import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -16,6 +17,7 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType import to.bitkit.services.CoreService import to.bitkit.utils.BlockTimeHelpers @@ -94,6 +96,39 @@ class TransferRepo @Inject constructor( } } + @Suppress("LongParameterList") + suspend fun createPendingToSpendingActivity( + order: IBtOrder, + txId: String, + fee: ULong, + feeRate: ULong, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { + "Order '${order.id}' has no on-chain payment address" + } + coreService.activity.createSentOnchainActivityFromSendResult( + txid = txId, + address = address, + amount = order.feeSat, + fee = fee, + feeRate = feeRate, + isTransfer = true, + channelId = order.channel?.shortChannelId, + ) + }.onFailure { + Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) + } + } + + suspend fun findLspOrderIdByFundingTxId(fundingTxId: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + transferDao.getByFundingTxId(fundingTxId)?.lspOrderId + }.onFailure { + Logger.warn("Failed to find transfer by funding txid '$fundingTxId'", it, context = TAG) + } + } + suspend fun syncTransferStates(): Result = withContext(bgDispatcher) { runCatching { val activeTransfers = transferDao.getActiveTransfers().first() diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index e68715ec3..238efb2c3 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -37,7 +37,7 @@ class DeriveBalanceStateUseCase @Inject constructor( val channels = lightningRepo.getChannels().orEmpty() val activeTransfers = transferRepo.activeTransfers.first() - val paidOrdersSats = getOrderPaymentsSats(activeTransfers) + val paidOrdersSats = getOrderPaymentsSats(activeTransfers, channels, balanceDetails) val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) @@ -69,25 +69,41 @@ class DeriveBalanceStateUseCase @Inject constructor( } } - private fun getOrderPaymentsSats(transfers: List): ULong { - return transfers - .filter { it.type.isToSpending() && it.lspOrderId != null } - .sumOf { it.amountSats.toULong() } + private suspend fun getOrderPaymentsSats( + transfers: List, + channels: List, + balances: BalanceDetails, + ): ULong { + var amount = 0uL + val paidOrders = transfers.filter { it.type.isToSpending() && it.lspOrderId != null } + + for (transfer in paidOrders) { + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channelBalance = channelId?.let { id -> + balances.lightningBalances.find { it.channelId() == id } + } + if (channelBalance == null) { + amount = amount.safe() + transfer.amountSats.toULong().safe() + } + } + + return amount } - private fun getPendingChannelsSats( + private suspend fun getPendingChannelsSats( transfers: List, channels: List, balances: BalanceDetails, ): ULong { var amount = 0uL - val pendingTransfers = transfers.filter { it.type.isToSpending() && it.channelId != null } + val pendingTransfers = transfers.filter { it.type.isToSpending() } for (transfer in pendingTransfers) { - val channel = channels.find { it.channelId == transfer.channelId } + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channel = channels.find { it.channelId == channelId } if (channel != null && !channel.isChannelReady) { val channelBalance = balances.lightningBalances.find { it.channelId() == channel.channelId } - amount += channelBalance?.amountSats() ?: 0u + amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } } @@ -105,7 +121,7 @@ class DeriveBalanceStateUseCase @Inject constructor( for (transfer in toSavings) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } - toSavingsAmount += channelBalance?.amountSats() ?: 0u + toSavingsAmount = toSavingsAmount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } return toSavingsAmount @@ -134,7 +150,7 @@ class DeriveBalanceStateUseCase @Inject constructor( for (transfer in transfers.filter { it.type == TransferType.COOP_CLOSE }) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } - amount += channelBalance?.amountSats() ?: 0u + amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } return amount } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 9d2978537..ec8c49ea6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -28,10 +28,11 @@ import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class ActivityDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -40,6 +41,7 @@ class ActivityDetailViewModel @Inject constructor( private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, private val hwWalletRepo: HwWalletRepo, + private val transferRepo: TransferRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() @@ -255,6 +257,14 @@ class ActivityDetailViewModel @Inject constructor( orders.firstOrNull { order -> order.payment?.onchain?.transactions?.any { it.txId == txId } == true }?.let { return@withContext it } + + val orderId = transferRepo.findLspOrderIdByFundingTxId(txId).getOrNull() + if (orderId != null) { + orders.find { it.id == orderId }?.let { return@withContext it } + blocktankRepo.getOrder(orderId, refresh = false).getOrNull()?.let { + return@withContext it + } + } } null diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 008f2aab2..91ab09e0c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -236,13 +236,27 @@ class TransferViewModel @Inject constructor( } /** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */ - private suspend fun fundPaidOrder(order: IBtOrder, txId: String) { + private suspend fun fundPaidOrder( + order: IBtOrder, + txId: String, + createTransferActivity: Boolean = false, + feeRate: ULong = 0uL, + ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) transferRepo.createTransfer( type = TransferType.TO_SPENDING, amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txId, lspOrderId = order.id, ) + if (createTransferActivity) { + transferRepo.createPendingToSpendingActivity( + order = order, + txId = txId, + fee = 0uL, + feeRate = feeRate, + ) + } viewModelScope.launch { walletRepo.syncBalances() } viewModelScope.launch { watchOrder(order.id) } } @@ -497,13 +511,20 @@ class TransferViewModel @Inject constructor( } } + val satsPerVByte = hwFundingSatsPerVByte() + hwWalletRepo.signAndBroadcastFunding( deviceId = deviceId, address = address, sats = order.feeSat, - satsPerVByte = hwFundingSatsPerVByte(), + satsPerVByte = satsPerVByte, ).onSuccess { txId -> - fundPaidOrder(order, txId) + fundPaidOrder( + order = order, + txId = txId, + createTransferActivity = true, + feeRate = satsPerVByte, + ) _spendingUiState.update { it.copy(isSigning = false) } setTransferEffect(TransferEffect.OnHwTxSigned) }.onFailure { e -> diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 3d31ef72f..d65386de5 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -8,9 +8,12 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.whenever @@ -32,6 +35,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val blocktankRepo = mock() private val settingsStore = mock() private val hwWalletRepo = mock() + private val transferRepo = mock() companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -45,6 +49,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) + runBlocking { + whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null)) + } sut = ActivityDetailViewModel( context = context, @@ -53,6 +60,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { blocktankRepo = blocktankRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + transferRepo = transferRepo, ) } @@ -153,6 +161,19 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertNull(result) } + @Test + fun `findOrderForTransfer finds pending order by transfer funding txId`() = test { + val txId = "funding-tx-id" + val order = mock { on { id } doReturn ORDER_ID } + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(orders = persistentListOf()))) + whenever(transferRepo.findLspOrderIdByFundingTxId(txId)).thenReturn(Result.success(ORDER_ID)) + whenever(blocktankRepo.getOrder(eq(ORDER_ID), eq(false))).thenReturn(Result.success(order)) + + val result = sut.findOrderForTransfer(null, txId) + + assertEquals(order, result) + } + @Test fun `loadActivity starts observation of activity changes`() = test { val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 585eb8f0f..fc3c54acc 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -46,6 +46,10 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenAnswer { invocation -> + val transfer = invocation.getArgument(0) + transfer.channelId + } wheneverBlocking { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) }.thenReturn(Result.success(1000uL)) @@ -208,6 +212,46 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should move LSP order transfer to pending channel once LDK reports the balance`() = test { + val channelId = "lsp-pending-channel-id" + val amountSats = 50_000uL + val channelBalance = newChannelBalance(channelId, amountSats) + val balance = newBalanceDetails().copy( + lightningBalances = listOf(channelBalance), + totalLightningBalanceSats = amountSats, + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + + val channel = mock { + on { this.channelId } doReturn channelId + on { isChannelReady } doReturn false + } + val transfers = listOf( + newTransferEntity( + type = TransferType.TO_SPENDING, + amountSats = amountSats.toLong(), + channelId = null, + lspOrderId = "lsp-order-id", + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenReturn(channelId) + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(amountSats, balanceState.balanceInTransferToSpending) + assertEquals( + 0uL, + balanceState.totalLightningSats, + "Lightning balance reduced while the discovered LSP channel is still pending" + ) + } + @Test fun `should not count manual channel as pending when ready`() = test { newBalanceDetails() diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index da084a553..f32859e64 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -17,6 +17,7 @@ import org.lightningdevkit.ldknode.NodeStatus import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -26,6 +27,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.models.HwWallet +import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState @@ -173,6 +175,20 @@ class TransferViewModelTest : BaseUnitTest() { eq(FEE_RATE), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + verify(transferRepo).createTransfer( + eq(TransferType.TO_SPENDING), + eq(order.clientBalanceSat.toLong()), + isNull(), + eq(TXID), + eq(order.id), + isNull(), + ) + verify(transferRepo).createPendingToSpendingActivity( + eq(order), + eq(TXID), + eq(0uL), + eq(FEE_RATE), + ) verify(hwWalletRepo, never()).reconnect(any()) } From c0a9e1c4a6196ae54ad9e7f8180a748574da4e55 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 01:55:20 +0200 Subject: [PATCH 11/29] fix: use configured trezor electrum --- .../to/bitkit/repositories/HwWalletRepo.kt | 2 +- .../java/to/bitkit/repositories/TrezorRepo.kt | 16 +++++--- .../ui/screens/trezor/TrezorViewModel.kt | 2 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 37 +++++++++++++++++++ .../ui/screens/trezor/TrezorViewModelTest.kt | 6 +-- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 6992f4568..2ba47c874 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -201,7 +201,7 @@ class HwWalletRepo @Inject constructor( psbtBase64 = success.psbt, network = Env.network.toTrezorCoinType(), ).getOrThrow() - trezorRepo.broadcastRawTx(serializedTx = signed.serializedTx, network = network).getOrThrow() + trezorRepo.broadcastRawTx(serializedTx = signed.serializedTx).getOrThrow() } } diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 745a92db4..b04c9cc76 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -49,6 +50,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.nowMs @@ -83,6 +85,7 @@ class TrezorRepo @Inject constructor( private val trezorTransport: TrezorTransport, private val trezorUiHandler: TrezorUiHandler, private val hwWalletStore: HwWalletStore, + private val settingsStore: SettingsStore, private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { @@ -389,7 +392,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getTransactionHistory( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = configuredElectrumUrl(), network = network, scriptType = scriptType, ) @@ -408,7 +411,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAccountInfo( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = configuredElectrumUrl(), network = network, scriptType = scriptType, ) @@ -426,7 +429,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAddressInfo( address = address, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = configuredElectrumUrl(), network = network, ) }.onFailure { e -> @@ -450,7 +453,7 @@ class TrezorRepo @Inject constructor( val params = ComposeParams( wallet = WalletParams( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = configuredElectrumUrl(), fingerprint = fingerprint, network = network, accountType = accountType, @@ -483,13 +486,12 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, - network: BitkitCoreNetwork, ): Result = withContext(ioDispatcher) { runCatching { awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = configuredElectrumUrl(), ) }.onFailure { Logger.error("Trezor broadcastRawTx failed", it, context = TAG) @@ -893,6 +895,8 @@ class TrezorRepo @Inject constructor( private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = Env.electrumUrlForNetwork(network) + private suspend fun configuredElectrumUrl(): String = settingsStore.data.first().electrumServer + private suspend fun ensureConnected() { if (trezorService.isConnected()) return val deviceId = _state.value.connectedDeviceId 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 aba53e9ed..ecc810b08 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 @@ -443,7 +443,7 @@ class TrezorViewModel @Inject constructor( val signedStep = state.sendStep as? SendStep.Signed ?: return@launch val rawTx = signedStep.signedTx.serializedTx _uiState.update { it.copy(send = it.send.copy(isBroadcasting = true)) } - trezorRepo.broadcastRawTx(serializedTx = rawTx, network = state.selectedNetwork) + trezorRepo.broadcastRawTx(serializedTx = rawTx) .onSuccess { txid -> TrezorDebugLog.log("BROADCAST", "SUCCESS txid=$txid") _uiState.update { diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 71c6f8298..05061c07b 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -2,6 +2,9 @@ package to.bitkit.repositories import android.content.Context import android.content.SharedPreferences +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures @@ -27,8 +30,11 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.models.TransportType +import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler @@ -65,8 +71,10 @@ class TrezorRepoTest : BaseUnitTest() { private val trezorTransport = mock() private val trezorUiHandler = mock() private val hwWalletStore = mock() + private val settingsStore = mock() private val prefs = mock() private val prefsEditor = mock() + private val settingsData = MutableStateFlow(SettingsData()) private lateinit var sut: TrezorRepo @@ -83,6 +91,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) + whenever(settingsStore.data).thenReturn(settingsData) whenever(context.filesDir).thenReturn(tempFolder.root) whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) } @@ -93,6 +102,7 @@ class TrezorRepoTest : BaseUnitTest() { trezorTransport = trezorTransport, trezorUiHandler = trezorUiHandler, hwWalletStore = hwWalletStore, + settingsStore = settingsStore, clock = Clock.System, ioDispatcher = testDispatcher, ) @@ -869,6 +879,33 @@ class TrezorRepoTest : BaseUnitTest() { // endregion + // region composeTransaction + + @Test + fun `composeTransaction should use configured electrum server`() = test { + val electrumServer = "ssl://custom.example:50002" + settingsData.value = SettingsData(electrumServer = electrumServer) + whenever(trezorService.getDeviceFingerprint()).thenReturn("fingerprint") + whenever(trezorService.composeTransaction(any())).thenReturn(emptyList()) + sut = createSut() + + val result = sut.composeTransaction( + extendedKey = "vpub", + outputs = listOf(ComposeOutput.Payment(address = TEST_ADDRESS, amountSats = 100uL)), + feeRates = listOf(1f), + network = Env.network.toCoreNetwork(), + accountType = null, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ) + + val params = argumentCaptor() + assertTrue(result.isSuccess) + verify(trezorService).composeTransaction(params.capture()) + assertEquals(electrumServer, params.firstValue.wallet.electrumUrl) + } + + // endregion + // region hasKnownDevices @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index f0bd80f63..66d1a9d94 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -262,14 +262,14 @@ class TrezorViewModelTest : BaseUnitTest() { sut.broadcastSignedTx() advanceUntilIdle() - verify(trezorRepo, never()).broadcastRawTx(any(), any()) + verify(trezorRepo, never()).broadcastRawTx(any()) } @Test fun `broadcastSignedTx should not restore signed step after reset`() = test { loadSignedTx() val broadcastResult = CompletableDeferred>() - whenever(trezorRepo.broadcastRawTx(any(), any())) + whenever(trezorRepo.broadcastRawTx(any())) .doSuspendableAnswer { broadcastResult.await() } sut.broadcastSignedTx() @@ -293,7 +293,7 @@ class TrezorViewModelTest : BaseUnitTest() { val broadcastResults = ArrayDeque( listOf(firstBroadcastResult, secondBroadcastResult) ) - whenever(trezorRepo.broadcastRawTx(any(), any())) + whenever(trezorRepo.broadcastRawTx(any())) .doSuspendableAnswer { broadcastResults.removeFirst().await() } sut.broadcastSignedTx() From 4dad88c1c1a59b533d3a40805945f40a3ad38d3f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 03:05:27 +0200 Subject: [PATCH 12/29] chore: group hw transfer screens --- app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt | 8 +------- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +++--- .../transfer/{ => hardware}/SpendingAmountHwScreen.kt | 2 +- .../transfer/{ => hardware}/SpendingHwSignScreen.kt | 3 ++- .../transfer/{ => hardware}/SpendingHwSignedScreen.kt | 3 ++- 5 files changed, 9 insertions(+), 13 deletions(-) rename app/src/main/java/to/bitkit/ui/screens/transfer/{ => hardware}/SpendingAmountHwScreen.kt (99%) rename app/src/main/java/to/bitkit/ui/screens/transfer/{ => hardware}/SpendingHwSignScreen.kt (98%) rename app/src/main/java/to/bitkit/ui/screens/transfer/{ => hardware}/SpendingHwSignedScreen.kt (97%) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 2ba47c874..800a17943 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -144,11 +144,6 @@ class HwWalletRepo @Inject constructor( /** Reconnects a known paired device so its session is live for on-device signing. */ suspend fun reconnect(deviceId: String): Result = trezorRepo.connectKnownDevice(deviceId) - /** - * Resolves the native-segwit funding account for a paired wallet: its account xpub, [AccountType] - * and the balance currently watched for that account. Used to compose the on-chain funding send the - * Trezor signs when transferring to spending. v1 funds from native segwit only. - */ suspend fun getFundingAccount(deviceId: String): Result = withContext(ioDispatcher) { runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() @@ -173,7 +168,7 @@ class HwWalletRepo @Inject constructor( /** * Composes the on-chain funding payment from the device's native-segwit account, has the Trezor sign - * it and broadcasts it. The signing step prompts the user on the device. Returns the broadcast txid. + * and broadcasts it. The signing step prompts the user on the device. Returns the broadcast txid. */ suspend fun signAndBroadcastFunding( deviceId: String, @@ -501,7 +496,6 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } -/** Native-segwit account used to fund a Trezor-signed transfer to spending. */ data class HwFundingAccount( val xpub: String, val accountType: AccountType, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3b3b8ccf6..29cc54498 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -118,11 +118,8 @@ import to.bitkit.ui.screens.transfer.SavingsIntroScreen import to.bitkit.ui.screens.transfer.SavingsProgressScreen import to.bitkit.ui.screens.transfer.SettingUpScreen import to.bitkit.ui.screens.transfer.SpendingAdvancedScreen -import to.bitkit.ui.screens.transfer.SpendingAmountHwScreen import to.bitkit.ui.screens.transfer.SpendingAmountScreen import to.bitkit.ui.screens.transfer.SpendingConfirmScreen -import to.bitkit.ui.screens.transfer.SpendingHwSignScreen -import to.bitkit.ui.screens.transfer.SpendingHwSignedScreen import to.bitkit.ui.screens.transfer.SpendingIntroScreen import to.bitkit.ui.screens.transfer.TransferIntroScreen import to.bitkit.ui.screens.transfer.external.ExternalAmountScreen @@ -131,6 +128,9 @@ import to.bitkit.ui.screens.transfer.external.ExternalConnectionScreen import to.bitkit.ui.screens.transfer.external.ExternalNodeViewModel import to.bitkit.ui.screens.transfer.external.ExternalSuccessScreen import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingAmountHwScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignedScreen import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.wallets.HardwareWalletScreen import to.bitkit.ui.screens.wallets.HomeScreen diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt similarity index 99% rename from app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt rename to app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index 2e626d0ed..56d08470f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.screens.transfer +package to.bitkit.ui.screens.transfer.hardware import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt similarity index 98% rename from app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt rename to app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index aa0fdee14..4a7e7c478 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.screens.transfer +package to.bitkit.ui.screens.transfer.hardware import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -33,6 +33,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt similarity index 97% rename from app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt rename to app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt index baaed2c0b..3085517e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingHwSignedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.screens.transfer +package to.bitkit.ui.screens.transfer.hardware import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -26,6 +26,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent From 5af487ebad8010869477e72c4915ffe720be4364 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 03:22:20 +0200 Subject: [PATCH 13/29] fix: match hw transfer figma visuals --- .../hardware/HardwareTransferIllustration.kt | 45 ++++++++++++++ .../transfer/hardware/SpendingHwSignScreen.kt | 60 ++++++++++--------- .../hardware/SpendingHwSignedScreen.kt | 20 ++----- 3 files changed, 82 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt new file mode 100644 index 000000000..5d1e1041f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt @@ -0,0 +1,45 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource + +/** Figma illustration width ratio from a 256px asset in a 375px frame. */ +private const val HARDWARE_VISUAL_WIDTH_RATIO = 256f / 375f + +/** Figma top ratio for the signed check visual within the content area below navigation. */ +internal const val SIGNED_VISUAL_TOP_RATIO = (481f - 92f) / (812f - 92f - 34f) + +/** Figma top ratio for the Trezor visual within the content area below navigation. */ +internal const val SIGN_VISUAL_TOP_RATIO = (488f - 92f) / (812f - 92f - 34f) + +@Composable +internal fun BoxScope.HardwareTransferIllustration( + modifier: Modifier = Modifier, + @DrawableRes drawableRes: Int, + topRatio: Float, +) { + BoxWithConstraints(modifier = Modifier.matchParentSize()) { + val visualSize = maxWidth * HARDWARE_VISUAL_WIDTH_RATIO + val topOffset = maxHeight * topRatio + + Image( + painter = painterResource(id = drawableRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = topOffset) + .size(visualSize) + .then(modifier) + ) + } +} 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 4a7e7c478..43186ef4b 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 @@ -1,23 +1,18 @@ package to.bitkit.ui.screens.transfer.hardware -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -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.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -78,13 +73,13 @@ fun SpendingHwSignScreen( @Composable private fun Content( order: IBtOrder, - isAdvanced: Boolean, - isSigning: Boolean, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onAdvancedClick: () -> Unit, - onUseDefaultLspBalanceClick: () -> Unit, - onOpenConnect: () -> Unit, + isAdvanced: Boolean = false, + isSigning: Boolean = false, + onBackClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onAdvancedClick: () -> Unit = {}, + onUseDefaultLspBalanceClick: () -> Unit = {}, + onOpenConnect: () -> Unit = {}, ) { ScreenColumn { AppTopBar( @@ -93,15 +88,9 @@ private fun Content( actions = { DrawerNavIcon() }, ) Box(modifier = Modifier.fillMaxSize()) { - Image( - painter = painterResource(id = R.drawable.trezor), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 48.dp) - .align(Alignment.BottomCenter) - .padding(bottom = 96.dp) + HardwareTransferIllustration( + drawableRes = R.drawable.trezor, + topRatio = SIGN_VISUAL_TOP_RATIO, ) Column( @@ -198,13 +187,28 @@ private fun Preview() { AppThemeSurface { Content( order = previewBtOrder(), - isAdvanced = false, - isSigning = false, - onBackClick = {}, - onLearnMoreClick = {}, - onAdvancedClick = {}, - onUseDefaultLspBalanceClick = {}, - onOpenConnect = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewAdvanced() { + AppThemeSurface { + Content( + order = previewBtOrder(), + isAdvanced = true, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewSigning() { + AppThemeSurface { + Content( + order = previewBtOrder(), + isSigning = true, ) } } 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 3085517e4..e4ea0aeed 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 @@ -1,19 +1,14 @@ package to.bitkit.ui.screens.transfer.hardware -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -32,8 +27,8 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.TransferViewModel -/** Dwell on the signed confirmation before forwarding, matching the transfer flow's checkmark beat. */ -private const val SIGNED_AUTO_NAV_DELAY_MS = 2500L +/** Figma handoff delay before forwarding from signed confirmation. */ +private const val SIGNED_AUTO_NAV_DELAY_MS = 1_000L @Composable fun SpendingHwSignedScreen( @@ -68,14 +63,9 @@ private fun Content( actions = { DrawerNavIcon() }, ) Box(modifier = Modifier.fillMaxSize()) { - Image( - painter = painterResource(id = R.drawable.check), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .align(Alignment.Center) - .padding(top = 120.dp) - .size(220.dp) + HardwareTransferIllustration( + drawableRes = R.drawable.check, + topRatio = SIGNED_VISUAL_TOP_RATIO, ) Column( From 249ba1ae5015587e0b7b5334bac36cedffe1dbfa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 03:34:52 +0200 Subject: [PATCH 14/29] fix: reconnect known ble trezors --- .../java/to/bitkit/repositories/TrezorRepo.kt | 29 +++++++++++++- .../to/bitkit/repositories/TrezorRepoTest.kt | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index b04c9cc76..c4ad2bc55 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -644,6 +644,8 @@ class TrezorRepo @Inject constructor( awaitSetup() TrezorDebugLog.log("RECONNECT", "Setup OK") TrezorDebugLog.log("RECONNECT", "Scanning for devices...") + val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + val knownDevice = knownDevices.find { it.matches(deviceId) } val scannedDevices = trezorService.scan() TrezorDebugLog.log( "RECONNECT", @@ -652,6 +654,7 @@ class TrezorRepo @Inject constructor( // Honor the transport the user selected — connect to exactly the // entry they tapped instead of overriding Bluetooth with USB. val device = scannedDevices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() ?: throw AppError("Device not found nearby — is it powered on?") TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") @@ -952,15 +955,27 @@ class TrezorRepo @Inject constructor( throw e } TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") - Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG) + Logger.warn("Failed to connect to '$deviceId', retrying", e, context = TAG) logCredentialFileState(deviceId, "BEFORE 2nd attempt") - val result = connectDevice(deviceId, selection, requestUsbPermission) + val result = runSuspendCatching { + connectDevice(deviceId, selection, requestUsbPermission) + }.onFailure { + disconnectAfterFailedConnect(deviceId) + }.getOrThrow() logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") TrezorDebugLog.log("THPRetry", "Second attempt succeeded") result } } + private suspend fun disconnectAfterFailedConnect(deviceId: String) { + runSuspendCatching { trezorService.disconnect() } + .onFailure { + Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) + } + _state.update { it.copy(connected = null) } + } + private suspend fun connectDevice( deviceId: String, selection: WalletSelection, @@ -1034,6 +1049,16 @@ data class KnownDevice( private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId +private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( + id = id, + transportType = transportType.toCoreTransportType(), + name = name, + path = path, + label = label, + model = model, + isBootloader = false, +) + private fun TrezorTransportType.toTransportType(): TransportType = when (this) { TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH TrezorTransportType.USB -> TransportType.USB diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 05061c07b..384195dfd 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -634,6 +634,21 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) } + @Test + fun `connect should disconnect stale session after retryable THP failures`() = test { + whenever(trezorService.connect(eq(DEVICE_ID), any())) + .thenThrow(RuntimeException("thp timeout")) + .thenThrow(RuntimeException("session timeout")) + sut = createSut() + + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isFailure) + assertNull(sut.state.value.connected) + verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) + verify(trezorService).disconnect() + } + @Test fun `connect should not retry non-retryable errors`() = test { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenThrow(RuntimeException("bad pin")) @@ -980,6 +995,29 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) } + @Test + fun `connectKnownDevice should use stored bluetooth device when scan misses active connection`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(emptyList()) + whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.connectKnownDevice(bleDeviceId) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + assertEquals(bleDeviceId, sut.state.value.connectedDeviceId) + verify(trezorService).connect(eq(bleDeviceId), any()) + } + // endregion // region clearError From ad7dc7a2f42d9954de4055da7174e9c38e4745da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 03:40:57 +0200 Subject: [PATCH 15/29] fix: match hw reconnect copy --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 281340411..c7a3a3a97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,7 +365,7 @@ Transfer Funds Swipe To Transfer Open Trezor Connect - Could not reach your Trezor. Reconnect the device and try again. + Please reconnect your hardware device, it appears to be disconnected from your phone. Reconnect Hardware Device Sign with\n<accent>your device</accent> Transaction\n<accent>signed</accent> From dac68503a113fff987ab8161efbc2cfad2edcaa9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 03:57:17 +0200 Subject: [PATCH 16/29] fix: refresh hw signing session --- .../to/bitkit/repositories/HwWalletRepo.kt | 46 ++++--- .../java/to/bitkit/repositories/TrezorRepo.kt | 20 ++- .../to/bitkit/viewmodels/TransferViewModel.kt | 117 ++++++++++++------ .../bitkit/repositories/HwWalletRepoTest.kt | 29 +++++ .../to/bitkit/repositories/TrezorRepoTest.kt | 19 +++ .../viewmodels/TransferViewModelTest.kt | 12 +- 6 files changed, 178 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 800a17943..a895acf1c 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -142,7 +142,10 @@ class HwWalletRepo @Inject constructor( } /** Reconnects a known paired device so its session is live for on-device signing. */ - suspend fun reconnect(deviceId: String): Result = trezorRepo.connectKnownDevice(deviceId) + suspend fun reconnect( + deviceId: String, + forceSession: Boolean = false, + ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) suspend fun getFundingAccount(deviceId: String): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -179,24 +182,29 @@ class HwWalletRepo @Inject constructor( runSuspendCatching { val account = getFundingAccount(deviceId).getOrThrow() val network = Env.network.toCoreNetwork() - val composed = trezorRepo.composeTransaction( - extendedKey = account.xpub, - outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), - feeRates = listOf(satsPerVByte.toFloat()), - network = network, - accountType = account.accountType, - coinSelection = CoinSelection.BRANCH_AND_BOUND, - ).getOrThrow() - val success = composed.filterIsInstance().firstOrNull() - ?: throw AppError( - composed.filterIsInstance().firstOrNull()?.error - ?: "Failed to compose hardware transfer" - ) - val signed = trezorRepo.signTxFromPsbt( - psbtBase64 = success.psbt, - network = Env.network.toTrezorCoinType(), - ).getOrThrow() - trezorRepo.broadcastRawTx(serializedTx = signed.serializedTx).getOrThrow() + val signed = runSuspendCatching { + val composed = trezorRepo.composeTransaction( + extendedKey = account.xpub, + outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), + feeRates = listOf(satsPerVByte.toFloat()), + network = network, + accountType = account.accountType, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ).getOrThrow() + val success = composed.filterIsInstance().firstOrNull() + ?: throw AppError( + composed.filterIsInstance().firstOrNull()?.error + ?: "Failed to compose hardware transfer" + ) + trezorRepo.signTxFromPsbt( + psbtBase64 = success.psbt, + network = Env.network.toTrezorCoinType(), + ).getOrThrow() + } + if (signed.isFailure) { + trezorRepo.disconnectStaleSession(deviceId) + } + trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() } } diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index c4ad2bc55..b4fb86fbe 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -449,6 +449,7 @@ class TrezorRepo @Inject constructor( ): Result> = withContext(ioDispatcher) { runCatching { awaitSetup() + ensureConnected() val fingerprint = trezorService.getDeviceFingerprint() val params = ComposeParams( wallet = WalletParams( @@ -632,7 +633,10 @@ class TrezorRepo @Inject constructor( return false } - suspend fun connectKnownDevice(deviceId: String): Result = withContext(ioDispatcher) { + suspend fun connectKnownDevice( + deviceId: String, + forceSession: Boolean = false, + ): Result = withContext(ioDispatcher) { if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) } @@ -643,6 +647,10 @@ class TrezorRepo @Inject constructor( TrezorDebugLog.log("RECONNECT", "Awaiting setup...") awaitSetup() TrezorDebugLog.log("RECONNECT", "Setup OK") + if (forceSession) { + TrezorDebugLog.log("RECONNECT", "Closing stale session before reconnect") + disconnectStaleSession(deviceId) + } TrezorDebugLog.log("RECONNECT", "Scanning for devices...") val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } val knownDevice = knownDevices.find { it.matches(deviceId) } @@ -906,8 +914,11 @@ class TrezorRepo @Inject constructor( ?: _state.value.knownDevices.firstOrNull()?.id ?: throw AppError("No device to reconnect") awaitSetup() + val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + val knownDevice = knownDevices.find { it.matches(deviceId) } val devices = trezorService.scan() val device = devices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() ?: throw AppError("Device not found during reconnect") val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) _state.update { it.copy(connected = ConnectedTrezorDevice(id = deviceId, features = features)) } @@ -960,7 +971,7 @@ class TrezorRepo @Inject constructor( val result = runSuspendCatching { connectDevice(deviceId, selection, requestUsbPermission) }.onFailure { - disconnectAfterFailedConnect(deviceId) + disconnectStaleSession(deviceId) }.getOrThrow() logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") TrezorDebugLog.log("THPRetry", "Second attempt succeeded") @@ -968,12 +979,13 @@ class TrezorRepo @Inject constructor( } } - private suspend fun disconnectAfterFailedConnect(deviceId: String) { - runSuspendCatching { trezorService.disconnect() } + suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { + val result = runSuspendCatching { trezorService.disconnect() } .onFailure { Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) } _state.update { it.copy(connected = null) } + result } private suspend fun connectDevice( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 91ab09e0c..45f82a048 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -7,7 +7,9 @@ import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R @@ -39,6 +42,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.math.min @@ -86,6 +90,7 @@ class TransferViewModel @Inject constructor( val transferEffects = MutableSharedFlow() fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } var maxLspFee = 0uL + private var hwTransferSignJob: Job? = null // region Spending @@ -447,8 +452,10 @@ class TransferViewModel @Inject constructor( } fun resetSpendingState() { - _spendingUiState.value = TransferToSpendingUiState() - _transferValues.value = TransferValues() + hwTransferSignJob?.cancel() + hwTransferSignJob = null + _spendingUiState.update { TransferToSpendingUiState() } + _transferValues.update { TransferValues() } } // endregion @@ -488,55 +495,84 @@ class TransferViewModel @Inject constructor( /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { - viewModelScope.launch { + if (hwTransferSignJob?.isActive == true) return + + hwTransferSignJob = viewModelScope.launch { _spendingUiState.update { it.copy(isSigning = true) } + try { + val address = order.payment?.onchain?.address.orEmpty() + if (address.isEmpty()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) + return@launch + } - val address = order.payment?.onchain?.address.orEmpty() - if (address.isEmpty()) { + signTransferToSpendingWithHardware(order, deviceId, address) + .onSuccess { (txId, satsPerVByte) -> + fundPaidOrder( + order = order, + txId = txId, + createTransferActivity = true, + feeRate = satsPerVByte, + ) + setTransferEffect(TransferEffect.OnHwTxSigned) + } + .onFailure { handleHardwareTransferFailure(it, deviceId) } + } finally { _spendingUiState.update { it.copy(isSigning = false) } - ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) - return@launch + hwTransferSignJob = null } + } + } - if (!isHwDeviceConnected(deviceId)) { - hwWalletRepo.reconnect(deviceId).onFailure { - Logger.error("Failed to reconnect hardware device", it, context = TAG) - _spendingUiState.update { s -> s.copy(isSigning = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), - description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description), - ) - return@launch - } + private suspend fun signTransferToSpendingWithHardware( + order: IBtOrder, + deviceId: String, + address: String, + ): Result> { + val result = runCatching { + withTimeout(HW_TRANSFER_SIGN_TIMEOUT) { + hwWalletRepo.reconnect(deviceId, forceSession = true) + .getOrElse { throw HardwareReconnectError(it) } + val satsPerVByte = hwFundingSatsPerVByte() + val txId = hwWalletRepo.signAndBroadcastFunding( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = satsPerVByte, + ).getOrThrow() + txId to satsPerVByte } + } + result.exceptionOrNull()?.let { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + } + return result + } - val satsPerVByte = hwFundingSatsPerVByte() - - hwWalletRepo.signAndBroadcastFunding( - deviceId = deviceId, - address = address, - sats = order.feeSat, - satsPerVByte = satsPerVByte, - ).onSuccess { txId -> - fundPaidOrder( - order = order, - txId = txId, - createTransferActivity = true, - feeRate = satsPerVByte, - ) - _spendingUiState.update { it.copy(isSigning = false) } - setTransferEffect(TransferEffect.OnHwTxSigned) - }.onFailure { e -> + private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { + when (e) { + is HardwareReconnectError -> { + Logger.error("Failed to reconnect hardware device", e, context = TAG) + showHardwareReconnectError() + } + is TimeoutCancellationException -> { + Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) + showHardwareReconnectError() + } + else -> { Logger.error("Hardware transfer failed", e, context = TAG) - _spendingUiState.update { it.copy(isSigning = false) } ToastEventBus.send(e) } } } - private fun isHwDeviceConnected(deviceId: String): Boolean = - hwWalletRepo.wallets.value.any { deviceId in it.deviceIds && it.isConnected } + private suspend fun showHardwareReconnectError() { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), + description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description), + ) + } private suspend fun hwFundingFeeReserve(): ULong = hwFundingSatsPerVByte().safe() * HW_FUNDING_TX_VBYTES.safe() @@ -754,6 +790,9 @@ class TransferViewModel @Inject constructor( /** Flat vbyte reserve for the funding tx; exact fee computed by the Trezor at sign time. */ private const val HW_FUNDING_TX_VBYTES = 200uL + + /** Upper bound for one hardware transfer signing attempt before the UI releases the button. */ + private val HW_TRANSFER_SIGN_TIMEOUT = 45.seconds const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -761,6 +800,8 @@ class TransferViewModel @Inject constructor( } } +private class HardwareReconnectError(cause: Throwable) : AppError(cause) + // region state data class TransferToSpendingUiState( val order: IBtOrder? = null, diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 5ee3bf89f..ecf76b1e1 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -703,6 +703,35 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).onAppForegrounded() } + @Test + fun `signAndBroadcastFunding disconnects stale session when compose fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever( + trezorRepo.composeTransaction( + extendedKey = any(), + outputs = any(), + feeRates = any(), + network = any(), + accountType = anyOrNull(), + coinSelection = any(), + ) + ).thenReturn(Result.failure(AppError("compose failed"))) + whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding( + deviceId = "dev1", + address = "bc1qtest", + sats = 25_000uL, + satsPerVByte = 2uL, + ) + + assertEquals(true, result.isFailure) + verify(trezorRepo).disconnectStaleSession("dev1") + verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + @Test fun `forwards pairing code calls to the trezor repo`() = test { val sut = createRepo() diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 384195dfd..7a64e8f7a 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -900,6 +900,7 @@ class TrezorRepoTest : BaseUnitTest() { fun `composeTransaction should use configured electrum server`() = test { val electrumServer = "ssl://custom.example:50002" settingsData.value = SettingsData(electrumServer = electrumServer) + whenever(trezorService.isConnected()).thenReturn(true) whenever(trezorService.getDeviceFingerprint()).thenReturn("fingerprint") whenever(trezorService.composeTransaction(any())).thenReturn(emptyList()) sut = createSut() @@ -995,6 +996,24 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) } + @Test + fun `connectKnownDevice should close stale session when forced`() = test { + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.connectKnownDevice(DEVICE_ID, forceSession = true) + + assertTrue(result.isSuccess) + verify(trezorService).disconnect() + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + } + @Test fun `connectKnownDevice should use stored bluetooth device when scan misses active connection`() = test { val bleDeviceId = "ble:57:21:A7:F9:DD:AD" diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index f32859e64..95d6157a7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -162,6 +163,8 @@ class TransferViewModelTest : BaseUnitTest() { val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.reconnect(DEVICE_ID, forceSession = true)) + .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.signAndBroadcastFunding(any(), any(), any(), any())).thenReturn(Result.success(TXID)) @@ -189,20 +192,21 @@ class TransferViewModelTest : BaseUnitTest() { eq(0uL), eq(FEE_RATE), ) - verify(hwWalletRepo, never()).reconnect(any()) + verify(hwWalletRepo).reconnect(DEVICE_ID, forceSession = true) } @Test - fun `onTransferToSpendingHwConfirm reconnects a disconnected device and aborts when it fails`() = test { + fun `onTransferToSpendingHwConfirm aborts when hardware reconnect fails`() = test { val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) - whenever(hwWalletRepo.reconnect(DEVICE_ID)).thenReturn(Result.failure(RuntimeException("no device"))) + whenever(hwWalletRepo.reconnect(DEVICE_ID, forceSession = true)) + .thenReturn(Result.failure(RuntimeException("no device"))) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() - verify(hwWalletRepo).reconnect(DEVICE_ID) + verify(hwWalletRepo).reconnect(DEVICE_ID, forceSession = true) verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any(), any(), any()) } From d36cea3a3037b7ff41718824f3812411b9b185a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 16:56:45 +0200 Subject: [PATCH 17/29] chore: fix previews text wrap --- .../main/java/to/bitkit/ui/components/NumberPadTextField.kt | 4 +++- .../to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt | 3 ++- .../to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 3 ++- .../ui/screens/transfer/hardware/SpendingAmountHwScreen.kt | 3 ++- .../ui/screens/transfer/hardware/SpendingHwSignScreen.kt | 6 ++++-- .../ui/screens/transfer/hardware/SpendingHwSignedScreen.kt | 6 ++++-- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index b158ff18f..aab4a2f7e 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -81,6 +81,7 @@ private fun MoneyAmount( } Row( verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { if (!isSymbolSuffix) { Display( @@ -101,7 +102,8 @@ private fun MoneyAmount( append(placeholder) } } - } + }, + modifier = if (isSymbolSuffix) Modifier else Modifier.weight(1f) ) if (isSymbolSuffix) { Display( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 9f6493966..ad1f623cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -176,7 +176,8 @@ private fun Content( Display( text = stringResource(R.string.lightning__spending_advanced__title) - .withAccent(accentColor = Colors.Purple) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) FillHeight() diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 984043d34..e6df0adae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -197,7 +197,8 @@ private fun SpendingAmountNodeRunning( Display( text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) FillHeight() diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index 56d08470f..58af27496 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -198,7 +198,8 @@ private fun NodeRunning( Display( text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) FillHeight() 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 43186ef4b..694d93692 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -101,8 +102,9 @@ private fun Content( ) { VerticalSpacer(32.dp) Display( - stringResource(R.string.lightning__transfer_hw__sign_title) - .withAccent(accentColor = Colors.Purple) + text = stringResource(R.string.lightning__transfer_hw__sign_title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(16.dp) 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 e4ea0aeed..e80e97b42 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 @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.transfer.hardware import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -76,8 +77,9 @@ private fun Content( ) { VerticalSpacer(32.dp) Display( - stringResource(R.string.lightning__transfer_hw__signed_title) - .withAccent(accentColor = Colors.Purple) + text = stringResource(R.string.lightning__transfer_hw__signed_title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) VerticalSpacer(16.dp) From 26ba2a2d7d41172890f027859cb4ef4f23c9a890 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 17:14:21 +0200 Subject: [PATCH 18/29] chore: code style cleanup --- .../main/java/to/bitkit/data/HwWalletStore.kt | 2 +- .../java/to/bitkit/ext/TrezorTransportType.kt | 9 +++ .../java/to/bitkit/models/HwFundingAccount.kt | 45 +++++++++++ .../main/java/to/bitkit/models/KnownDevice.kt | 20 +++++ .../to/bitkit/repositories/HwWalletRepo.kt | 34 ++++---- .../java/to/bitkit/repositories/TrezorRepo.kt | 81 +++++++------------ app/src/main/java/to/bitkit/ui/ContentView.kt | 8 -- .../ui/components/HwWalletComponents.kt | 32 ++++++++ .../transfer/SpendingAdvancedScreen.kt | 3 +- .../screens/transfer/SpendingAmountScreen.kt | 2 +- .../hardware/HardwareTransferIllustration.kt | 45 ----------- .../hardware/SpendingAmountHwScreen.kt | 42 +++++----- .../transfer/hardware/SpendingHwSignScreen.kt | 17 ++-- .../hardware/SpendingHwSignedScreen.kt | 9 ++- .../ui/screens/trezor/DeviceListSection.kt | 2 +- .../ui/screens/trezor/TrezorPreviewData.kt | 2 +- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 16 ++-- .../ui/screens/trezor/TrezorViewModel.kt | 2 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 6 +- .../bitkit/repositories/HwWalletRepoTest.kt | 1 + .../to/bitkit/repositories/TrezorRepoTest.kt | 39 ++++----- .../viewmodels/TransferViewModelTest.kt | 14 +++- 22 files changed, 233 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ext/TrezorTransportType.kt create mode 100644 app/src/main/java/to/bitkit/models/HwFundingAccount.kt create mode 100644 app/src/main/java/to/bitkit/models/KnownDevice.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt diff --git a/app/src/main/java/to/bitkit/data/HwWalletStore.kt b/app/src/main/java/to/bitkit/data/HwWalletStore.kt index 79f0cc58c..08cd6ab19 100644 --- a/app/src/main/java/to/bitkit/data/HwWalletStore.kt +++ b/app/src/main/java/to/bitkit/data/HwWalletStore.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import to.bitkit.data.serializers.HwWalletDataSerializer import to.bitkit.di.IoDispatcher -import to.bitkit.repositories.KnownDevice +import to.bitkit.models.KnownDevice import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt b/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt new file mode 100644 index 000000000..ab234c0ee --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt @@ -0,0 +1,9 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.models.TransportType + +fun TrezorTransportType.toTransportType(): TransportType = when (this) { + TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH + TrezorTransportType.USB -> TransportType.USB +} diff --git a/app/src/main/java/to/bitkit/models/HwFundingAccount.kt b/app/src/main/java/to/bitkit/models/HwFundingAccount.kt new file mode 100644 index 000000000..07ef6bca0 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwFundingAccount.kt @@ -0,0 +1,45 @@ +package to.bitkit.models + +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.AddressType + +sealed interface HwFundingAccount { + val vendor: HwWalletVendor + val xpub: String + val addressType: HwFundingAddressType + val accountType: AccountType + val balanceSats: ULong + + data class Trezor( + override val xpub: String, + override val addressType: HwFundingAddressType, + override val balanceSats: ULong, + ) : HwFundingAccount { + override val vendor: HwWalletVendor = HwWalletVendor.TREZOR + override val accountType: AccountType + get() = addressType.accountType + } +} + +enum class HwWalletVendor { + TREZOR, +} + +enum class HwFundingAddressType( + val addressType: AddressType, +) { + LEGACY(AddressType.P2PKH), + NESTED_SEGWIT(AddressType.P2SH), + NATIVE_SEGWIT(AddressType.P2WPKH), + TAPROOT(AddressType.P2TR); + + val settingsKey: String + get() = addressType.toSettingsString() + + val accountType: AccountType + get() = addressType.toAccountType() + + companion object { + val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE } + } +} diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt new file mode 100644 index 000000000..a9bd19150 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -0,0 +1,20 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class KnownDevice( + val id: String, + val name: String?, + val path: String, + val transportType: TransportType, + val label: String?, + val model: String?, + val lastConnectedAt: Long, + /** Account-level extended public keys per address type. */ + val xpubs: Map = emptyMap(), + /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ + val customLabel: String? = null, +) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index a895acf1c..25fb46957 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -1,6 +1,5 @@ package to.bitkit.repositories -import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.ComposeOutput @@ -41,10 +40,11 @@ import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching -import to.bitkit.models.DEFAULT_ADDRESS_TYPE -import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.HwFundingAccount +import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.safe import to.bitkit.models.toAccountType @@ -147,32 +147,32 @@ class HwWalletRepo @Inject constructor( forceSession: Boolean = false, ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) - suspend fun getFundingAccount(deviceId: String): Result = withContext(ioDispatcher) { + suspend fun getFundingAccount( + deviceId: String, + addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, + ): Result = withContext(ioDispatcher) { runSuspendCatching { val devices = hwWalletStore.loadKnownDevices() val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() - val xpub = requireNotNull(target.xpubs[DEFAULT_ADDRESS_TYPE_STRING]) { - "Hardware wallet '$deviceId' has no native-segwit account" + val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { + "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" } val balanceSats = _watcherData.value .filterKeys { key -> - key.substringAfter(WATCHER_ID_SEPARATOR) == DEFAULT_ADDRESS_TYPE_STRING && + key.substringAfter(WATCHER_ID_SEPARATOR) == addressType.settingsKey && key.toDeviceId() in groupIds } .values.fold(0uL) { acc, watcher -> acc + watcher.balanceSats } - HwFundingAccount( + HwFundingAccount.Trezor( xpub = xpub, - accountType = DEFAULT_ADDRESS_TYPE.toAccountType(), + addressType = addressType, balanceSats = balanceSats, ) } } - /** - * Composes the on-chain funding payment from the device's native-segwit account, has the Trezor sign - * and broadcasts it. The signing step prompts the user on the device. Returns the broadcast txid. - */ + /** Composes, signs on the Trezor, and broadcasts the on-chain funding payment. */ suspend fun signAndBroadcastFunding( deviceId: String, address: String, @@ -264,7 +264,7 @@ class HwWalletRepo @Inject constructor( .filter { it.xpubs.isNotEmpty() } .groupBy { it.walletKey } .map { (_, devices) -> - val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId } + val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } val ids = devices.map { it.id }.toSet() val deviceWatchers = watcherData.values.filter { it.deviceId in ids } @@ -504,12 +504,6 @@ class HwWalletRepo @Inject constructor( private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) } -data class HwFundingAccount( - val xpub: String, - val accountType: AccountType, - val balanceSats: ULong, -) - private data class WatcherSettings( val monitoredTypes: Set, val electrumUrl: String, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index b4fb86fbe..07d9550e6 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import android.content.Context -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType @@ -48,14 +47,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env 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.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork @@ -219,7 +219,7 @@ class TrezorRepo @Inject constructor( passphrase: String = "", ): Result = withContext(ioDispatcher) { runCatching { - val deviceId = _state.value.connectedDeviceId + val deviceId = _state.value.connectedDeviceId() ?: throw AppError("No connected Trezor") TrezorDebugLog.log("WALLET_MODE", "Switching to $mode, resetting session for $deviceId") // Reset the session via disconnect/reconnect. disconnect() resets the @@ -392,7 +392,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getTransactionHistory( extendedKey = extendedKey, - electrumUrl = configuredElectrumUrl(), + electrumUrl = currentElectrumUrl(), network = network, scriptType = scriptType, ) @@ -411,7 +411,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAccountInfo( extendedKey = extendedKey, - electrumUrl = configuredElectrumUrl(), + electrumUrl = currentElectrumUrl(), network = network, scriptType = scriptType, ) @@ -429,7 +429,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAddressInfo( address = address, - electrumUrl = configuredElectrumUrl(), + electrumUrl = currentElectrumUrl(), network = network, ) }.onFailure { e -> @@ -454,7 +454,7 @@ class TrezorRepo @Inject constructor( val params = ComposeParams( wallet = WalletParams( extendedKey = extendedKey, - electrumUrl = configuredElectrumUrl(), + electrumUrl = currentElectrumUrl(), fingerprint = fingerprint, network = network, accountType = accountType, @@ -492,7 +492,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, - electrumUrl = configuredElectrumUrl(), + electrumUrl = currentElectrumUrl(), ) }.onFailure { Logger.error("Trezor broadcastRawTx failed", it, context = TAG) @@ -501,7 +501,7 @@ class TrezorRepo @Inject constructor( } suspend fun disconnect(): Result = withContext(ioDispatcher) { - TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId()}") val result = runCatching { trezorService.disconnect() } // Mirror the core: trezorService.disconnect() resets the session // passphrase to the standard wallet, so reset the UI handler's wallet @@ -590,7 +590,7 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(isAutoReconnecting = true, error = null) } runCatching { awaitSetup(walletIndex) - val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice else null + val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice() else null if (cachedFeatures != null) { cachedFeatures } else { @@ -642,40 +642,38 @@ class TrezorRepo @Inject constructor( } runCatching { _state.update { it.copy(isConnecting = true, error = null) } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") - TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") - TrezorDebugLog.log("RECONNECT", "Awaiting setup...") + Logger.debug("Started known-device reconnect for '$deviceId'", context = TAG) + Logger.debug("Awaiting setup for reconnect", context = TAG) awaitSetup() - TrezorDebugLog.log("RECONNECT", "Setup OK") + Logger.debug("Completed setup for reconnect", context = TAG) if (forceSession) { - TrezorDebugLog.log("RECONNECT", "Closing stale session before reconnect") + Logger.debug("Closing stale session before reconnect for '$deviceId'", context = TAG) disconnectStaleSession(deviceId) } - TrezorDebugLog.log("RECONNECT", "Scanning for devices...") + Logger.debug("Scanning for reconnect devices", context = TAG) val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } val knownDevice = knownDevices.find { it.matches(deviceId) } val scannedDevices = trezorService.scan() - TrezorDebugLog.log( - "RECONNECT", - "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}", + Logger.debug( + "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", + context = TAG, ) // Honor the transport the user selected — connect to exactly the // entry they tapped instead of overriding Bluetooth with USB. val device = scannedDevices.find { it.id == deviceId } ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() ?: throw AppError("Device not found nearby — is it powered on?") - TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") - TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") + Logger.debug("Found reconnect device '${device.id}'", context = TAG) + Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) - TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") + Logger.debug("Connected known device '${device.id}'", context = TAG) addOrUpdateKnownDevice(device, features) _state.update { it.copy(isConnecting = false, connected = ConnectedTrezorDevice(id = device.id, features = features)) } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") + Logger.info("Reconnected known device '${device.id}'", context = TAG) features }.onFailure { e -> - TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") Logger.error("Connect known device failed", e, context = TAG) _state.update { it.copy(isConnecting = false, error = e.message) } } @@ -684,7 +682,7 @@ class TrezorRepo @Inject constructor( suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") - val disconnectResult = if (_state.value.connectedDeviceId == deviceId) { + val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { runCatching { trezorService.disconnect() }.also { // Clear any cached host passphrase so it can't be reused // against a different device on a later connect. @@ -775,7 +773,7 @@ class TrezorRepo @Inject constructor( private fun observeExternalDisconnects() { trezorTransport.externalDisconnect.onEach { path -> - val currentId = _state.value.connectedDeviceId ?: return@onEach + val currentId = _state.value.connectedDeviceId() ?: return@onEach val knownDevice = _state.value.knownDevices.find { it.path == path } if (knownDevice?.id == currentId || path.contains(currentId)) { Logger.warn("External disconnect detected for '$currentId'", context = TAG) @@ -906,11 +904,11 @@ class TrezorRepo @Inject constructor( private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = Env.electrumUrlForNetwork(network) - private suspend fun configuredElectrumUrl(): String = settingsStore.data.first().electrumServer + private suspend fun currentElectrumUrl(): String = settingsStore.data.first().electrumServer private suspend fun ensureConnected() { if (trezorService.isConnected()) return - val deviceId = _state.value.connectedDeviceId + val deviceId = _state.value.connectedDeviceId() ?: _state.value.knownDevices.firstOrNull()?.id ?: throw AppError("No device to reconnect") awaitSetup() @@ -1030,11 +1028,9 @@ data class TrezorState( val lastPublicKey: TrezorPublicKeyResponse? = null, val error: String? = null, ) { - val connectedDevice: TrezorFeatures? - get() = connected?.features + fun connectedDevice(): TrezorFeatures? = connected?.features - val connectedDeviceId: String? - get() = connected?.id + fun connectedDeviceId(): String? = connected?.id } @Stable @@ -1043,22 +1039,6 @@ data class ConnectedTrezorDevice( val features: TrezorFeatures, ) -@Serializable -@Immutable -data class KnownDevice( - val id: String, - val name: String?, - val path: String, - val transportType: TransportType, - val label: String?, - val model: String?, - val lastConnectedAt: Long, - /** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */ - val xpubs: Map = emptyMap(), - /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ - val customLabel: String? = null, -) - private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( @@ -1071,11 +1051,6 @@ private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( isBootloader = false, ) -private fun TrezorTransportType.toTransportType(): TransportType = when (this) { - TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH - TrezorTransportType.USB -> TransportType.USB -} - private fun TransportType.toCoreTransportType(): TrezorTransportType = when (this) { TransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH TransportType.USB -> TrezorTransportType.USB diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 29cc54498..dc8deebf1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -781,14 +781,6 @@ private fun RootNavHost( isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(deviceId)) }, - toastException = { appViewModel.toast(it) }, - toast = { title, description -> - appViewModel.toast( - type = Toast.ToastType.ERROR, - title = title, - description = description, - ) - }, ) } composableWithDefaultTransitions { entry -> diff --git a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt index e4c2bec4e..7f0c520bb 100644 --- a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt +++ b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt @@ -1,6 +1,8 @@ package to.bitkit.ui.components +import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.offset @@ -11,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -26,6 +29,12 @@ import to.bitkit.ui.theme.Colors /** Illustration width as a fraction of the sheet width — the 256-wide Visual in the 375-wide Figma frame. */ internal const val HW_ILLUSTRATION_SIZE_RATIO = 256f / 375f +/** Figma top ratio for the signed check visual within the content area below navigation. */ +internal const val SIGNED_VISUAL_TOP_RATIO = (481f - 92f) / (812f - 92f - 34f) + +/** Figma top ratio for the Trezor visual within the content area below navigation. */ +internal const val SIGN_VISUAL_TOP_RATIO = (488f - 92f) / (812f - 92f - 34f) + /** Trezor illustration left bleed past the frame, as a fraction of the sheet width (Figma device frames). */ private const val HW_DEVICE_TREZOR_BLEED_RATIO = 84f / 375f @@ -83,6 +92,29 @@ fun HwWalletConnectionIcon( ) } +@Composable +internal fun BoxScope.HardwareTransferIllustration( + modifier: Modifier = Modifier, + @DrawableRes drawableRes: Int, + topRatio: Float, +) { + BoxWithConstraints(modifier = Modifier.matchParentSize()) { + val visualSize = maxWidth * HW_ILLUSTRATION_SIZE_RATIO + val topOffset = maxHeight * topRatio + + Image( + painter = painterResource(id = drawableRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = topOffset) + .size(visualSize) + .then(modifier) + ) + } +} + @Composable private fun BoxWithConstraintsScope.TrezorImage( imageSize: Dp, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index ad1f623cc..d91a05306 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -93,7 +93,6 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() - TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) @@ -107,6 +106,8 @@ fun SpendingAdvancedScreen( description = effect.description, ) } + + else -> Unit } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index e6df0adae..ec1b4349d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -83,9 +83,9 @@ fun SpendingAmountScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> onOrderCreated() - TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastError -> toast(effect.title, effect.description) is TransferEffect.ToastException -> toastException(effect.e) + else -> Unit } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt deleted file mode 100644 index 5d1e1041f..000000000 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/HardwareTransferIllustration.kt +++ /dev/null @@ -1,45 +0,0 @@ -package to.bitkit.ui.screens.transfer.hardware - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource - -/** Figma illustration width ratio from a 256px asset in a 375px frame. */ -private const val HARDWARE_VISUAL_WIDTH_RATIO = 256f / 375f - -/** Figma top ratio for the signed check visual within the content area below navigation. */ -internal const val SIGNED_VISUAL_TOP_RATIO = (481f - 92f) / (812f - 92f - 34f) - -/** Figma top ratio for the Trezor visual within the content area below navigation. */ -internal const val SIGN_VISUAL_TOP_RATIO = (488f - 92f) / (812f - 92f - 34f) - -@Composable -internal fun BoxScope.HardwareTransferIllustration( - modifier: Modifier = Modifier, - @DrawableRes drawableRes: Int, - topRatio: Float, -) { - BoxWithConstraints(modifier = Modifier.matchParentSize()) { - val visualSize = maxWidth * HARDWARE_VISUAL_WIDTH_RATIO - val topOffset = maxHeight * topRatio - - Image( - painter = painterResource(id = drawableRes), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = topOffset) - .size(visualSize) - .then(modifier) - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index 58af27496..5d0431673 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies @@ -46,6 +47,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -64,8 +66,6 @@ fun SpendingAmountHwScreen( isOffline: Boolean, onBackClick: () -> Unit = {}, onOrderCreated: () -> Unit = {}, - toastException: (Throwable) -> Unit, - toast: (title: String, description: String) -> Unit, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { @@ -84,9 +84,13 @@ fun SpendingAmountHwScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> onOrderCreated() - TransferEffect.OnHwTxSigned -> Unit - is TransferEffect.ToastError -> toast(effect.title, effect.description) - is TransferEffect.ToastException -> toastException(effect.e) + is TransferEffect.ToastError -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = effect.title, + description = effect.description, + ) + is TransferEffect.ToastException -> ToastEventBus.send(effect.e) + else -> Unit } } } @@ -96,9 +100,10 @@ fun SpendingAmountHwScreen( when (it) { AmountInputEffect.MaxExceeded -> { amountInputViewModel.setSats(currentMaxAllowedToSend, currentCurrencies) - toast( - context.getString(R.string.lightning__spending_amount__error_max__title), - context.getString(R.string.lightning__spending_amount__error_max__description) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString(R.string.lightning__spending_amount__error_max__description) .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), ) } @@ -137,13 +142,13 @@ fun SpendingAmountHwScreen( @Suppress("ViewModelForwarding") @Composable private fun Content( - isNodeRunning: Boolean, + isNodeRunning: Boolean = true, uiState: TransferToSpendingUiState, amountInputViewModel: AmountInputViewModel, - onBackClick: () -> Unit, - onClickQuarter: () -> Unit, - onClickMaxAmount: () -> Unit, - onConfirmAmount: () -> Unit, + onBackClick: () -> Unit = {}, + onClickQuarter: () -> Unit = {}, + onClickMaxAmount: () -> Unit = {}, + onConfirmAmount: () -> Unit = {}, currencies: CurrencyState = LocalCurrencies.current, ) { ScreenColumn { @@ -178,9 +183,9 @@ private fun NodeRunning( uiState: TransferToSpendingUiState, amountInputViewModel: AmountInputViewModel, currencies: CurrencyState, - onClickQuarter: () -> Unit, - onClickMaxAmount: () -> Unit, - onConfirmAmount: () -> Unit, + onClickQuarter: () -> Unit = {}, + onClickMaxAmount: () -> Unit = {}, + onConfirmAmount: () -> Unit = {}, ) { LaunchedEffect(uiState.maxAllowedToSend) { amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) @@ -279,14 +284,9 @@ private fun NodeRunning( private fun Preview() { AppThemeSurface { Content( - isNodeRunning = true, uiState = TransferToSpendingUiState(maxAllowedToSend = 158_234, balanceAfterFee = 158_234), amountInputViewModel = previewAmountInputViewModel(), currencies = CurrencyState(), - onBackClick = {}, - onClickQuarter = {}, - onClickMaxAmount = {}, - onConfirmAmount = {}, ) } } 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 694d93692..5de144a58 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 @@ -24,7 +24,9 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.FeeInfo import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HardwareTransferIllustration import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SIGN_VISUAL_TOP_RATIO import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -40,11 +42,11 @@ import to.bitkit.viewmodels.TransferViewModel fun SpendingHwSignScreen( deviceId: String, viewModel: TransferViewModel, - onBackClick: () -> Unit = {}, - onCloseClick: () -> Unit = {}, - onLearnMoreClick: () -> Unit = {}, - onAdvancedClick: () -> Unit = {}, - onSigned: () -> Unit = {}, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onAdvancedClick: () -> Unit, + onSigned: () -> Unit, ) { val state by viewModel.spendingUiState.collectAsStateWithLifecycle() @@ -55,7 +57,10 @@ fun SpendingHwSignScreen( LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> - if (effect is TransferEffect.OnHwTxSigned) onSigned() + when (effect) { + TransferEffect.OnHwTxSigned -> onSigned() + else -> Unit + } } } 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 e80e97b42..707fc9154 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 @@ -18,6 +18,8 @@ import com.synonym.bitkitcore.IBtOrder import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ui.components.Display +import to.bitkit.ui.components.HardwareTransferIllustration +import to.bitkit.ui.components.SIGNED_VISUAL_TOP_RATIO import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -34,8 +36,8 @@ private const val SIGNED_AUTO_NAV_DELAY_MS = 1_000L @Composable fun SpendingHwSignedScreen( viewModel: TransferViewModel, - onContinue: () -> Unit = {}, - onCloseClick: () -> Unit = {}, + onContinue: () -> Unit, + onCloseClick: () -> Unit, ) { val state by viewModel.spendingUiState.collectAsStateWithLifecycle() @@ -55,7 +57,7 @@ fun SpendingHwSignedScreen( @Composable private fun Content( order: IBtOrder, - onBackClick: () -> Unit, + onBackClick: () -> Unit = {}, ) { ScreenColumn { AppTopBar( @@ -95,7 +97,6 @@ private fun Preview() { AppThemeSurface { Content( order = previewBtOrder(), - onBackClick = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index 37958ed0f..0abfa7ee5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType -import to.bitkit.repositories.KnownDevice import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index a0b8d3e86..6a0bdac66 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -19,9 +19,9 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import com.synonym.bitkitcore.Network as BitkitCoreNetwork diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index e461a0842..4636ee60f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -48,8 +48,8 @@ import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.models.KnownDevice import to.bitkit.repositories.ConnectedTrezorDevice -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorWalletMode @@ -251,7 +251,7 @@ private fun Content( if (trezorState.connected != null) { WalletModeRow( walletMode = walletMode, - passphraseEntryCapable = trezorState.connectedDevice?.passphraseEntryCapable == true, + passphraseEntryCapable = trezorState.connectedDevice()?.passphraseEntryCapable == true, onSetWalletMode = onSetWalletMode, ) } @@ -270,7 +270,7 @@ private fun Content( ) VerticalSpacer(8.dp) trezorState.knownDevices.forEach { device -> - val isConnected = trezorState.connectedDeviceId == device.id + val isConnected = trezorState.connectedDeviceId() == device.id KnownDeviceCard( device = device, isConnected = isConnected, @@ -315,11 +315,11 @@ private fun Content( // Connected Device Info AnimatedVisibility( - visible = trezorState.connectedDevice != null, + visible = trezorState.connectedDevice() != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - trezorState.connectedDevice?.let { features -> + trezorState.connectedDevice()?.let { features -> Column { VerticalSpacer(32.dp) Caption13Up( @@ -406,7 +406,7 @@ private fun Content( VerticalSpacer(32.dp) BalanceLookupSection( uiState = uiState, - isDeviceConnected = trezorState.connectedDevice != null, + isDeviceConnected = trezorState.connectedDevice() != null, onInputChange = onLookupInputChange, onAccountTypeChange = onLookupAccountTypeChange, onLookup = onLookup, @@ -668,7 +668,7 @@ private fun StatusRow(trezorState: TrezorState) { Caption("Connecting...", color = Colors.White64) } - trezorState.connectedDevice != null -> { + trezorState.connectedDevice() != null -> { StatusBadge(text = "Connected", color = Colors.Green) } @@ -704,7 +704,7 @@ private fun ActionButtonsRow( modifier = Modifier.fillMaxWidth() ) { if (trezorState.isAutoReconnecting) return@Row - if (trezorState.connectedDevice != null) { + if (trezorState.connectedDevice() != null) { SecondaryButton( text = "Disconnect", onClick = onDisconnect, 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 ecc810b08..b4772d978 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 @@ -31,10 +31,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorRepo import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorWalletMode diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 45f82a048..3b067f095 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -460,12 +460,8 @@ class TransferViewModel @Inject constructor( // endregion - // region Spending HW (Trezor watch-only transfer to spending) + // region Hardware Wallet - /** - * Computes AVAILABLE/MAX for a Trezor transfer from the device's native-segwit account balance, - * reserving an on-chain fee for the funding send the device signs. Reuses the spending limit math. - */ fun updateHwLimits(deviceId: String) { viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index ecf76b1e1..690ca10d2 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -30,6 +30,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.test.BaseUnitTest diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 7a64e8f7a..71b685562 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.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.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorService @@ -527,8 +528,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(features, sut.state.value.connectedDevice) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(features, sut.state.value.connectedDevice()) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) assertFalse(sut.state.value.isConnecting) } @@ -686,13 +687,13 @@ class TrezorRepoTest : BaseUnitTest() { sut.scan() sut.connect(DEVICE_ID) - assertEquals(features, sut.state.value.connectedDevice) + assertEquals(features, sut.state.value.connectedDevice()) val result = sut.disconnect() assertTrue(result.isSuccess) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.lastAddress) assertNull(sut.state.value.lastPublicKey) } @@ -732,8 +733,8 @@ class TrezorRepoTest : BaseUnitTest() { val result = sut.disconnect() assertTrue(result.isFailure) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.lastAddress) assertNull(sut.state.value.lastPublicKey) assertEquals("disconnect failed", sut.state.value.error) @@ -754,7 +755,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(sut.state.value.knownDevices.isEmpty()) assertTrue(sut.state.value.nearbyDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) + assertNull(sut.state.value.connectedDevice()) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) verify(hwWalletStore).reset() @@ -970,7 +971,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) assertFalse(sut.state.value.isAutoReconnecting) } @@ -993,7 +994,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) } @Test @@ -1011,7 +1012,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) verify(trezorService).disconnect() - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) } @Test @@ -1033,7 +1034,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(bleDeviceId, sut.state.value.connectedDeviceId) + assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) verify(trezorService).connect(eq(bleDeviceId), any()) } @@ -1100,7 +1101,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(addressResponse, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) verify(trezorService).scan() verify(trezorService).connect(eq(DEVICE_ID), any()) } @@ -1128,8 +1129,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(sut.state.value.knownDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) @@ -1155,8 +1156,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(sut.state.value.knownDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertEquals("clear failed", result.exceptionOrNull()?.message) assertEquals("clear failed", sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) @@ -1194,8 +1195,8 @@ class TrezorRepoTest : BaseUnitTest() { assertFalse(state.isAutoReconnecting) assertTrue(state.knownDevices.isEmpty()) assertTrue(state.nearbyDevices.isEmpty()) - assertNull(state.connectedDevice) - assertNull(state.connectedDeviceId) + assertNull(state.connectedDevice()) + assertNull(state.connectedDeviceId()) assertNull(state.lastAddress) assertNull(state.lastPublicKey) assertNull(state.error) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 95d6157a7..7f39eaf94 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1,7 +1,6 @@ package to.bitkit.viewmodels import android.content.Context -import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo @@ -27,12 +26,13 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState +import to.bitkit.models.HwFundingAccount +import to.bitkit.models.HwFundingAddressType import to.bitkit.models.HwWallet import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState -import to.bitkit.repositories.HwFundingAccount import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -146,7 +146,15 @@ class TransferViewModelTest : BaseUnitTest() { // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) - .thenReturn(Result.success(HwFundingAccount(XPUB, AccountType.NATIVE_SEGWIT, ON_CHAIN_BALANCE))) + .thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = ON_CHAIN_BALANCE, + ), + ), + ) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) From 2ecf4d18b466a907701b7f8a1f9394d79586f64c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 18:08:21 +0200 Subject: [PATCH 19/29] fix: harden hardware transfer flow --- .../main/java/to/bitkit/models/KnownDevice.kt | 2 + .../to/bitkit/repositories/ActivityRepo.kt | 20 +++ .../to/bitkit/repositories/HwWalletRepo.kt | 79 +++++++++--- .../to/bitkit/repositories/TransferRepo.kt | 34 +++-- .../java/to/bitkit/repositories/TrezorRepo.kt | 73 ++++++++++- .../to/bitkit/services/TrezorTransport.kt | 43 +++++- .../to/bitkit/viewmodels/TransferViewModel.kt | 122 +++++++++++++++--- .../bitkit/repositories/ActivityRepoTest.kt | 72 +++++++++++ .../bitkit/repositories/HwWalletRepoTest.kt | 109 +++++++++++++++- .../bitkit/repositories/TransferRepoTest.kt | 73 +++++++++++ .../to/bitkit/repositories/TrezorRepoTest.kt | 87 +++++++++++++ .../usecases/DeriveBalanceStateUseCaseTest.kt | 47 ++++++- .../viewmodels/TransferViewModelTest.kt | 35 +++-- changelog.d/next/1039.added.md | 2 +- 14 files changed, 725 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt index a9bd19150..478afb926 100644 --- a/app/src/main/java/to/bitkit/models/KnownDevice.kt +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -17,4 +17,6 @@ data class KnownDevice( val xpubs: Map = emptyMap(), /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ val customLabel: String? = null, + /** Stable app-owned id for future wallet-scoped hardware activity metadata. */ + val walletId: String = "", ) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 17b632559..7dfb8f4d1 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -213,6 +213,26 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } + suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + runCatching { + val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching + val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp + .takeIf { activity.confirmed } + val updated = existing.copy( + confirmed = existing.confirmed || activity.confirmed, + confirmTimestamp = confirmTimestamp, + doesExist = if (activity.confirmed) true else existing.doesExist, + fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee, + updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp), + ) + if (updated == existing) return@runCatching + coreService.activity.update(existing.id, Activity.Onchain(updated)) + notifyActivitiesChanged() + }.onFailure { + Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + } + } + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { coreService.activity.handleOnchainTransactionReplaced(txid, conflicts) notifyActivitiesChanged() diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 25fb46957..33a098919 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -72,6 +72,7 @@ import kotlin.time.ExperimentalTime @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, + private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, private val clock: Clock, @@ -147,6 +148,8 @@ class HwWalletRepo @Inject constructor( forceSession: Boolean = false, ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) + suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + suspend fun getFundingAccount( deviceId: String, addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, @@ -172,39 +175,61 @@ class HwWalletRepo @Inject constructor( } } - /** Composes, signs on the Trezor, and broadcasts the on-chain funding payment. */ - suspend fun signAndBroadcastFunding( + /** Composes the exact on-chain funding payment before prompting for the Trezor signature. */ + suspend fun composeFundingTransaction( deviceId: String, address: String, sats: ULong, satsPerVByte: ULong, - ): Result = withContext(ioDispatcher) { + ): Result = withContext(ioDispatcher) { runSuspendCatching { val account = getFundingAccount(deviceId).getOrThrow() val network = Env.network.toCoreNetwork() + val composed = trezorRepo.composeTransaction( + extendedKey = account.xpub, + outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), + feeRates = listOf(satsPerVByte.toFloat()), + network = network, + accountType = account.accountType, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ).getOrThrow() + val success = composed.filterIsInstance().firstOrNull() + ?: throw AppError( + composed.filterIsInstance().firstOrNull()?.error + ?: "Failed to compose hardware transfer" + ) + HwFundingTransaction( + psbt = success.psbt, + miningFeeSats = success.fee, + feeRate = success.feeRate, + totalSpent = success.totalSpent, + satsPerVByte = satsPerVByte, + ) + } + } + + /** Signs a composed funding payment on the Trezor and broadcasts it. */ + suspend fun signAndBroadcastFunding( + deviceId: String, + funding: HwFundingTransaction, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { val signed = runSuspendCatching { - val composed = trezorRepo.composeTransaction( - extendedKey = account.xpub, - outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), - feeRates = listOf(satsPerVByte.toFloat()), - network = network, - accountType = account.accountType, - coinSelection = CoinSelection.BRANCH_AND_BOUND, - ).getOrThrow() - val success = composed.filterIsInstance().firstOrNull() - ?: throw AppError( - composed.filterIsInstance().firstOrNull()?.error - ?: "Failed to compose hardware transfer" - ) trezorRepo.signTxFromPsbt( - psbtBase64 = success.psbt, + psbtBase64 = funding.psbt, network = Env.network.toTrezorCoinType(), ).getOrThrow() } if (signed.isFailure) { trezorRepo.disconnectStaleSession(deviceId) } - trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() + val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() + HwFundingBroadcastResult( + txId = txId, + miningFeeSats = funding.miningFeeSats, + feeRate = funding.satsPerVByte, + totalSpent = funding.totalSpent, + ) } } @@ -328,6 +353,9 @@ class HwWalletRepo @Inject constructor( ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } + activities.filterIsInstance().forEach { + activityRepo.syncHardwareOnchainActivity(it.v1) + } emitReceivedTxs(previous, event, updatedWatcherData) } } @@ -538,3 +566,18 @@ private data class HwWatcherData( val transactions: ImmutableList, val activities: ImmutableList, ) + +data class HwFundingTransaction( + val psbt: String, + val miningFeeSats: ULong, + val feeRate: Float, + val totalSpent: ULong, + val satsPerVByte: ULong, +) + +data class HwFundingBroadcastResult( + val txId: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 3427ae7e1..584066edf 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -129,6 +129,7 @@ class TransferRepo @Inject constructor( } } + @Suppress("CyclomaticComplexMethod") suspend fun syncTransferStates(): Result = withContext(bgDispatcher) { runCatching { val activeTransfers = transferDao.getActiveTransfers().first() @@ -143,6 +144,7 @@ class TransferRepo @Inject constructor( for (transfer in toSpending) { val channelId = resolveChannelIdForTransfer(transfer, channels) + channelId?.let { persistResolvedChannel(transfer, it) } val channel = channelId?.let { channels.find { c -> c.channelId == it } } if (channel != null && channel.isChannelReady) { markSettled(transfer.id) @@ -230,9 +232,17 @@ class TransferRepo @Inject constructor( Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG) } + private suspend fun persistResolvedChannel(transfer: TransferEntity, channelId: String) { + if (transfer.channelId == null) { + transferDao.update(transfer.copy(channelId = channelId)) + Logger.debug("Persisted channel '$channelId' for transfer '${transfer.id}'", context = TAG) + } + transfer.fundingTxId?.let { markActivityAsTransfer(it, channelId) } + } + private suspend fun markActivityAsTransfer(txid: String, channelId: String) { val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return - if (activity.isTransfer) return + if (activity.isTransfer && activity.channelId == channelId) return val updated = activity.copy(isTransfer = true, channelId = channelId) coreService.activity.update(activity.id, Activity.Onchain(updated)) Logger.debug("Marked activity ${activity.id} as transfer for channel $channelId", context = TAG) @@ -252,18 +262,24 @@ class TransferRepo @Inject constructor( Logger.debug("Marked activity ${activity.v1.id} as transfer for channel $channelId", context = TAG) } - /** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */ + /** Resolve channelId: direct transfer data first, then LSP order metadata, then funding tx fallback. */ suspend fun resolveChannelIdForTransfer( transfer: TransferEntity, channels: List, ): String? { - return transfer.lspOrderId - ?.let { orderId -> - val order = blocktankRepo.getOrder(orderId, refresh = false).getOrNull() - val fundingTxId = order?.channel?.fundingTx?.id ?: return null - return@let channels.find { it.fundingTxo?.txid == fundingTxId }?.channelId - } - ?: transfer.channelId + transfer.channelId?.let { return it } + + val orderFundingTxId = transfer.lspOrderId?.let { orderId -> + blocktankRepo.getOrder(orderId, refresh = false).getOrNull() + ?.channel + ?.fundingTx + ?.id + } + val fundingTxId = orderFundingTxId ?: transfer.fundingTxId + + return fundingTxId?.let { txid -> + channels.find { it.fundingTxo?.txid == txid }?.channelId + } } companion object { diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 07d9550e6..41ab8f57f 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -69,6 +69,7 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -501,8 +502,13 @@ class TrezorRepo @Inject constructor( } suspend fun disconnect(): Result = withContext(ioDispatcher) { - TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId()}") - val result = runCatching { trezorService.disconnect() } + val deviceId = _state.value.connectedDeviceId() + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=$deviceId") + val result = runCatching { + trezorService.disconnect() + deviceId?.let { disconnectTransportDevice(it) } + Unit + } // Mirror the core: trezorService.disconnect() resets the session // passphrase to the standard wallet, so reset the UI handler's wallet // mode too. This keeps the THP path, the legacy PassphraseRequest @@ -679,11 +685,22 @@ class TrezorRepo @Inject constructor( } } + suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { + val current = _state.value.connected + if (current?.id == deviceId && trezorService.isConnected()) { + return@withContext Result.success(current.features) + } + connectKnownDevice(deviceId, forceSession = false) + } + suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { - runCatching { trezorService.disconnect() }.also { + runCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) + }.also { // Clear any cached host passphrase so it can't be reused // against a different device on a later connect. trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD) @@ -853,6 +870,7 @@ class TrezorRepo @Inject constructor( val storedIds = stored.map { it.id }.toSet() val knownDevices = stored + _state.value.knownDevices.filter { it.id !in storedIds } val previous = knownDevices.find { it.id == deviceInfo.id } + val xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs() val known = KnownDevice( id = deviceInfo.id, name = deviceInfo.name, @@ -861,8 +879,9 @@ class TrezorRepo @Inject constructor( label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, lastConnectedAt = clock.nowMs(), - xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs(), + xpubs = xpubs, customLabel = previous?.customLabel, + walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -891,7 +910,12 @@ class TrezorRepo @Inject constructor( } private suspend fun loadKnownDevices(): List = runCatching { - hwWalletStore.loadKnownDevices() + val devices = hwWalletStore.loadKnownDevices() + val migrated = devices.withHardwareWalletIds() + if (migrated != devices) { + hwWalletStore.saveKnownDevices(migrated) + } + migrated }.onFailure { Logger.error("Failed to load known devices", it, context = TAG) }.getOrDefault(emptyList()) @@ -978,7 +1002,10 @@ class TrezorRepo @Inject constructor( } suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { - val result = runSuspendCatching { trezorService.disconnect() } + val result = runSuspendCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) + } .onFailure { Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) } @@ -986,6 +1013,13 @@ class TrezorRepo @Inject constructor( result } + private suspend fun disconnectTransportDevice(deviceId: String) { + val knownDevice = (_state.value.knownDevices + loadKnownDevices()) + .distinctBy { it.id } + .find { it.matches(deviceId) } + trezorTransport.disconnectDevice(knownDevice?.path ?: deviceId) + } + private suspend fun connectDevice( deviceId: String, selection: WalletSelection, @@ -1041,6 +1075,33 @@ data class ConnectedTrezorDevice( private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId +private val KnownDevice.walletKey: String + get() = walletKey(xpubs, id) + +private fun walletKey(xpubs: Map, fallback: String): String = + xpubs.values.sorted().joinToString().ifEmpty { fallback } + +private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { + val walletKey = walletKey(xpubs, deviceId) + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } + ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } + ?: newHardwareWalletId() +} + +private fun List.withHardwareWalletIds(): List { + val existingByWallet = filter { it.walletId.isNotBlank() } + .associate { it.walletKey to it.walletId } + val generatedByWallet = mutableMapOf() + + return map { + val walletId = existingByWallet[it.walletKey] + ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + if (it.walletId == walletId) it else it.copy(walletId = walletId) + } +} + +private fun newHardwareWalletId(): String = UUID.randomUUID().toString() + private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( id = id, transportType = transportType.toCoreTransportType(), diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 8aacd8a72..cba4965b5 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -323,6 +323,17 @@ class TrezorTransport @Inject constructor( } } + fun disconnectDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("DISCONNECT", "disconnectDevice: $path") + return if (bridgeTransport.isBridgeDevice(path)) { + bridgeTransport.closeDevice(path) + } else if (isBleDevice(path)) { + disconnectBleDevice(path) + } else { + closeUsbDevice(path) + } + } + override fun readChunk(path: String): TrezorTransportReadResult { return if (bridgeTransport.isBridgeDevice(path)) { bridgeTransport.readChunk(path) @@ -889,8 +900,19 @@ class TrezorTransport @Inject constructor( return null } + @Suppress("ReturnCount") @SuppressLint("MissingPermission") private fun openBleDevice(path: String): TrezorTransportWriteResult { + bleConnections[path]?.takeIf { it.isConnected && it.writeCharacteristic != null }?.let { + val staleCount = it.readQueue.size + if (staleCount > 0) { + it.readQueue.clear() + TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") + } + Logger.info("Reused open BLE device '$path'", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + val address = path.removePrefix("ble:") // Prefer a handle from a recent scan, but fall back to resolving the // address directly so we can reconnect to a known device without a @@ -899,7 +921,7 @@ class TrezorTransport @Inject constructor( ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") - closeBleDevice(path) + bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } val bondError = waitForBonding(device, address) if (bondError != null) return bondError @@ -917,13 +939,13 @@ class TrezorTransport @Inject constructor( bleConnections[path] = connection if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - closeBleDevice(path) + disconnectBleDevice(path) return TrezorTransportWriteResult(success = false, error = "Connection timeout") } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { - closeBleDevice(path) + disconnectBleDevice(path) return TrezorTransportWriteResult(success = false, error = "Failed to connect") } @@ -948,6 +970,19 @@ class TrezorTransport @Inject constructor( val connection = bleConnections[path] ?: return TrezorTransportWriteResult(success = true, error = "") + connection.readQueue.clear() + connection.writeLatch?.countDown() + connection.connectionLatch?.countDown() + Logger.info("Closed BLE device session '$path'", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("MissingPermission") + private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { + val connection = bleConnections[path] + ?: return TrezorTransportWriteResult(success = true, error = "") + userInitiatedCloseSet.add(path) return try { val disconnectLatch = CountDownLatch(1) @@ -1316,6 +1351,6 @@ class TrezorTransport @Inject constructor( fun closeAllConnections() { usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } - bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } + bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 3b067f095..1d04ca9a1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -37,6 +37,8 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwFundingBroadcastResult +import to.bitkit.repositories.HwFundingTransaction import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -55,7 +57,7 @@ import kotlin.time.ExperimentalTime const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @OptIn(ExperimentalTime::class) @HiltViewModel class TransferViewModel @Inject constructor( @@ -245,6 +247,7 @@ class TransferViewModel @Inject constructor( order: IBtOrder, txId: String, createTransferActivity: Boolean = false, + fee: ULong = 0uL, feeRate: ULong = 0uL, ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) @@ -258,7 +261,7 @@ class TransferViewModel @Inject constructor( transferRepo.createPendingToSpendingActivity( order = order, txId = txId, - fee = 0uL, + fee = fee, feeRate = feeRate, ) } @@ -503,12 +506,13 @@ class TransferViewModel @Inject constructor( } signTransferToSpendingWithHardware(order, deviceId, address) - .onSuccess { (txId, satsPerVByte) -> + .onSuccess { result -> fundPaidOrder( order = order, - txId = txId, + txId = result.txId, createTransferActivity = true, - feeRate = satsPerVByte, + fee = result.miningFeeSats, + feeRate = result.feeRate, ) setTransferEffect(TransferEffect.OnHwTxSigned) } @@ -524,25 +528,73 @@ class TransferViewModel @Inject constructor( order: IBtOrder, deviceId: String, address: String, - ): Result> { + ): Result { val result = runCatching { - withTimeout(HW_TRANSFER_SIGN_TIMEOUT) { - hwWalletRepo.reconnect(deviceId, forceSession = true) - .getOrElse { throw HardwareReconnectError(it) } - val satsPerVByte = hwFundingSatsPerVByte() - val txId = hwWalletRepo.signAndBroadcastFunding( + ensureHardwareConnected(deviceId) + val satsPerVByte = hwFundingSatsPerVByte() + val funding = composeHardwareFundingTransaction( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = satsPerVByte, + ) + signAndBroadcastHardwareFunding(deviceId, funding) + } + result.exceptionOrNull()?.let { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + } + return result + } + + private suspend fun ensureHardwareConnected(deviceId: String) { + runCatching { + withTimeout(HW_RECONNECT_TIMEOUT) { + hwWalletRepo.ensureConnected(deviceId).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareReconnectError(it) + } + } + + private suspend fun composeHardwareFundingTransaction( + deviceId: String, + address: String, + sats: ULong, + satsPerVByte: ULong, + ): HwFundingTransaction { + return runCatching { + withTimeout(HW_COMPOSE_TIMEOUT) { + hwWalletRepo.composeFundingTransaction( deviceId = deviceId, address = address, - sats = order.feeSat, + sats = sats, satsPerVByte = satsPerVByte, ).getOrThrow() - txId to satsPerVByte } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareFundingError(it) } - result.exceptionOrNull()?.let { + } + + @Suppress("ThrowsCount") + private suspend fun signAndBroadcastHardwareFunding( + deviceId: String, + funding: HwFundingTransaction, + ): HwFundingBroadcastResult { + return runCatching { + withTimeout(HW_SIGN_TIMEOUT) { + hwWalletRepo.signAndBroadcastFunding( + deviceId = deviceId, + funding = funding, + ).getOrThrow() + } + }.getOrElse { if (it is CancellationException && it !is TimeoutCancellationException) throw it + if (it is TimeoutCancellationException) throw HardwareSigningTimeoutError(it) + throw it } - return result } private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { @@ -551,9 +603,13 @@ class TransferViewModel @Inject constructor( Logger.error("Failed to reconnect hardware device", e, context = TAG) showHardwareReconnectError() } - is TimeoutCancellationException -> { + is HardwareSigningTimeoutError -> { Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) - showHardwareReconnectError() + showHardwareTimeoutError() + } + is HardwareFundingError -> { + Logger.warn("Failed to compose hardware transfer funding for '$deviceId'", e, context = TAG) + showHardwareFundingError(e) } else -> { Logger.error("Hardware transfer failed", e, context = TAG) @@ -570,6 +626,22 @@ class TransferViewModel @Inject constructor( ) } + private suspend fun showHardwareTimeoutError() { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__toast_payment_failed_timeout), + ) + } + + private suspend fun showHardwareFundingError(e: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = e.cause?.message ?: e.message ?: context.getString(R.string.common__error_body), + ) + } + private suspend fun hwFundingFeeReserve(): ULong = hwFundingSatsPerVByte().safe() * HW_FUNDING_TX_VBYTES.safe() private suspend fun hwFundingSatsPerVByte(): ULong { @@ -784,11 +856,17 @@ class TransferViewModel @Inject constructor( private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 - /** Flat vbyte reserve for the funding tx; exact fee computed by the Trezor at sign time. */ - private const val HW_FUNDING_TX_VBYTES = 200uL + /** Conservative vbyte reserve for multi-input hardware funding before exact compose runs. */ + private const val HW_FUNDING_TX_VBYTES = 1_200uL + + /** Upper bound for reconnecting a known device before the UI asks for reconnect. */ + private val HW_RECONNECT_TIMEOUT = 30.seconds + + /** Upper bound for exact hardware funding composition before signing starts. */ + private val HW_COMPOSE_TIMEOUT = 45.seconds - /** Upper bound for one hardware transfer signing attempt before the UI releases the button. */ - private val HW_TRANSFER_SIGN_TIMEOUT = 45.seconds + /** Upper bound for one hardware signing attempt before the UI releases the button. */ + private val HW_SIGN_TIMEOUT = 120.seconds const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -797,6 +875,8 @@ 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) // region state data class TransferToSpendingUiState( diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 31f121974..286fcdd67 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,6 +15,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -261,6 +262,77 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(testActivity, result.getOrThrow()) } + @Test + fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { + val existing = createOnchainActivity( + id = "transfer-txid", + txId = "transfer-txid", + value = 50_000uL, + fee = 0uL, + feeRate = 2uL, + address = "bc1qlsp", + confirmed = false, + timestamp = 1_000uL, + isTransfer = true, + channelId = "channel-1", + isBoosted = true, + boostTxIds = listOf("boost-txid"), + contact = "contact", + ).v1 + val watcher = OnchainActivity.create( + id = "transfer-txid", + txType = PaymentType.SENT, + txId = "transfer-txid", + value = 49_000uL, + fee = 1_250uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + ) + whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + + val result = sut.syncHardwareOnchainActivity(watcher) + + assertTrue(result.isSuccess) + val captor = argumentCaptor() + verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) + val updated = (captor.firstValue as Activity.Onchain).v1 + assertTrue(updated.confirmed) + assertEquals(2_000uL, updated.confirmTimestamp) + assertEquals(true, updated.doesExist) + assertEquals(50_000uL, updated.value) + assertEquals(1_250uL, updated.fee) + assertEquals(2uL, updated.feeRate) + assertEquals("bc1qlsp", updated.address) + assertEquals(true, updated.isTransfer) + assertEquals("channel-1", updated.channelId) + assertEquals(true, updated.isBoosted) + assertEquals(listOf("boost-txid"), updated.boostTxIds) + assertEquals("contact", updated.contact) + } + + @Test + fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { + val watcher = OnchainActivity.create( + id = "hardware-only-txid", + txType = PaymentType.RECEIVED, + txId = "hardware-only-txid", + value = 10_000uL, + fee = 0uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + ) + whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + + val result = sut.syncHardwareOnchainActivity(watcher) + + assertTrue(result.isSuccess) + verify(coreService.activity, never()).update(any(), any()) + verify(coreService.activity, never()).insert(any()) + verify(coreService.activity, never()).upsert(any()) + } + @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 690ca10d2..9d6fddc62 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -2,9 +2,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -12,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -33,6 +36,7 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toTrezorCoinType import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -47,6 +51,7 @@ import kotlin.time.Instant class HwWalletRepoTest : BaseUnitTest() { private val trezorRepo = mock() + private val activityRepo = mock() private val hwWalletStore = mock() private val settingsStore = mock() private val clock = mock() @@ -77,10 +82,20 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + runBlocking { + whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) + } whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) } - private fun createRepo() = HwWalletRepo(trezorRepo, hwWalletStore, settingsStore, clock, testDispatcher) + private fun createRepo() = HwWalletRepo( + trezorRepo = trezorRepo, + activityRepo = activityRepo, + hwWalletStore = hwWalletStore, + settingsStore = settingsStore, + clock = clock, + ioDispatcher = testDispatcher, + ) @Test fun `lists a known device with zero balance before any watcher event`() = test { @@ -142,6 +157,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1, wallet.activities.size) assertEquals(1, sut.activities.value.size) assertEquals(Activity.Onchain::class, wallet.activities.single()::class) + verify(activityRepo).syncHardwareOnchainActivity((wallet.activities.single() as Activity.Onchain).v1) } @Test @@ -705,7 +721,42 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signAndBroadcastFunding disconnects stale session when compose fails`() = test { + fun `composeFundingTransaction returns composed fee data`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val composeResult = ComposeResult.Success( + psbt = "psbt", + fee = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + ) + whenever( + trezorRepo.composeTransaction( + extendedKey = any(), + outputs = any(), + feeRates = any(), + network = any(), + accountType = anyOrNull(), + coinSelection = any(), + ) + ).thenReturn(Result.success(listOf(composeResult))) + val sut = createRepo() + + val result = sut.composeFundingTransaction( + deviceId = "dev1", + address = "bc1qtest", + sats = 25_000uL, + satsPerVByte = 2uL, + ) + + assertEquals(true, result.isSuccess) + assertEquals("psbt", result.getOrThrow().psbt) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + assertEquals(2uL, result.getOrThrow().satsPerVByte) + } + + @Test + fun `composeFundingTransaction does not sign when compose fails`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) whenever( trezorRepo.composeTransaction( @@ -717,10 +768,9 @@ class HwWalletRepoTest : BaseUnitTest() { coinSelection = any(), ) ).thenReturn(Result.failure(AppError("compose failed"))) - whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) val sut = createRepo() - val result = sut.signAndBroadcastFunding( + val result = sut.composeFundingTransaction( deviceId = "dev1", address = "bc1qtest", sats = 25_000uL, @@ -728,9 +778,58 @@ class HwWalletRepoTest : BaseUnitTest() { ) assertEquals(true, result.isFailure) - verify(trezorRepo).disconnectStaleSession("dev1") verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).disconnectStaleSession(any()) + } + + @Test + fun `signAndBroadcastFunding returns txid and composed fee data`() = test { + val signedTx = TrezorSignedTx( + signatures = emptyList(), + serializedTx = "rawtx", + txid = "signed-txid", + ) + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) + .thenReturn(Result.success(signedTx)) + whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding("dev1", funding) + + assertEquals(true, result.isSuccess) + assertEquals("broadcast-txid", result.getOrThrow().txId) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(2uL, result.getOrThrow().feeRate) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + } + + @Test + fun `signAndBroadcastFunding disconnects stale session when sign fails`() = test { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) + .thenReturn(Result.failure(AppError("sign failed"))) + whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding("dev1", funding) + + assertEquals(true, result.isFailure) + verify(trezorRepo).disconnectStaleSession("dev1") + verify(trezorRepo, never()).broadcastRawTx(any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 68f699344..68104ccb9 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -273,6 +273,51 @@ class TransferRepoTest : BaseUnitTest() { verify(transferDao, never()).markSettled(any(), any()) } + @Test + fun `syncTransferStates persists resolved channel and marks activity for TO_SPENDING transfer`() = test { + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.TO_SPENDING, + amountSats = 50000L, + channelId = null, + fundingTxId = fundingTxo.txid, + lspOrderId = ID_ORDER, + isSettled = false, + createdAt = 1000L, + ) + val channelDetails = createChannelDetails().copy( + channelId = ID_CHANNEL, + fundingTxo = fundingTxo, + isChannelReady = false, + ) + val activity = OnchainActivity.create( + id = fundingTxo.txid, + txType = PaymentType.SENT, + txId = fundingTxo.txid, + value = 50_000uL, + fee = 0uL, + address = "bc1qtest", + timestamp = 1000uL, + isTransfer = true, + channelId = null, + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(listOf(channelDetails)) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(mock())) + whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)).thenReturn(Result.success(null)) + whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid)).thenReturn(activity) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).update(transfer.copy(channelId = ID_CHANNEL)) + verify(activityService).update( + eq(activity.id), + eq(Activity.Onchain(activity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) + } + @Test fun `syncTransferStates does not settle TO_SPENDING transfer when channel not found`() = test { val transfer = TransferEntity( @@ -794,6 +839,34 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(ID_CHANNEL, result) } + @Test + fun `resolveChannelIdForTransfer finds channel via transfer funding tx when order lacks channel data`() = test { + val order = mock() + whenever(order.channel).thenReturn(null) + + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.TO_SPENDING, + amountSats = 50000L, + channelId = null, + fundingTxId = fundingTxo.txid, + lspOrderId = ID_ORDER, + isSettled = false, + createdAt = 1000L, + ) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(fundingTxo) + whenever(channelDetails.channelId).thenReturn(ID_CHANNEL) + + whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)) + .thenReturn(Result.success(order)) + + val result = sut.resolveChannelIdForTransfer(transfer, listOf(channelDetails)) + + assertEquals(ID_CHANNEL, result) + } + @Test fun `resolveChannelIdForTransfer returns null when LSP order not found`() = test { val transfer = TransferEntity( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 71b685562..dd9728cbc 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -11,6 +11,7 @@ import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorTransportType +import com.synonym.bitkitcore.TrezorTransportWriteResult import com.synonym.bitkitcore.WalletSelection import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -41,6 +42,7 @@ import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -90,6 +92,9 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) + whenever(trezorTransport.disconnectDevice(any())).thenReturn( + TrezorTransportWriteResult(success = true, error = "") + ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) whenever(settingsStore.data).thenReturn(settingsData) @@ -158,6 +163,7 @@ class TrezorRepoTest : BaseUnitTest() { transportType: TransportType = TransportType.USB, xpubs: Map = emptyMap(), customLabel: String? = null, + walletId: String = "wallet-id", ) = KnownDevice( id = id, name = name, @@ -168,6 +174,7 @@ class TrezorRepoTest : BaseUnitTest() { lastConnectedAt = 123L, xpubs = xpubs, customLabel = customLabel, + walletId = walletId, ) // region initialize @@ -185,6 +192,23 @@ class TrezorRepoTest : BaseUnitTest() { assertNull(sut.state.value.error) } + @Test + fun `initialize assigns wallet ids to restored devices missing them`() = test { + val knownDevice = mockKnownDevice(walletId = "") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + val savedCaptor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) + val saved = savedCaptor.firstValue.single() + assertEquals(knownDevice.id, saved.id) + assertNotNull(UUID.fromString(saved.walletId)) + assertEquals(listOf(saved), sut.state.value.knownDevices) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -552,6 +576,48 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) + assertNotNull(UUID.fromString(saved.walletId)) + } + + @Test + fun `connect reuses wallet id from same xpub wallet`() = test { + val walletId = "hardware-wallet-id" + val nativeSegwitPath = "m/84'/1'/0'" + val previousDevice = mockKnownDevice( + id = "ble-device", + path = "ble:AA:BB", + transportType = TransportType.BLUETOOTH, + xpubs = mapOf("nativeSegwit" to "same-native-xpub"), + walletId = walletId, + ) + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "same-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } @Test @@ -1038,6 +1104,27 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService).connect(eq(bleDeviceId), any()) } + @Test + fun `ensureConnected returns current selected device without reconnecting`() = test { + val features = mockFeatures() + 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.isSuccess) + assertEquals(features, result.getOrNull()) + verify(trezorService, times(1)).scan() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + verify(trezorService, never()).disconnect() + } + // endregion // region clearError diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index fc3c54acc..a5ad9620f 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -10,6 +10,7 @@ import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BalanceSource import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.OutPoint import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn @@ -252,6 +253,49 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should not double count LSP transfer when pending channel resolves before order refresh`() = test { + val channelId = "lsp-pending-channel-id" + val fundingTxId = "funding-tx-id" + val amountSats = 50_000uL + val channelBalance = newChannelBalance(channelId, amountSats) + val balance = newBalanceDetails().copy( + lightningBalances = listOf(channelBalance), + totalLightningBalanceSats = amountSats, + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + + val channel = mock { + on { this.channelId } doReturn channelId + on { isChannelReady } doReturn false + on { fundingTxo } doReturn OutPoint(txid = fundingTxId, vout = 0u) + } + val transfers = listOf( + newTransferEntity( + type = TransferType.TO_SPENDING, + amountSats = amountSats.toLong(), + channelId = null, + fundingTxId = fundingTxId, + lspOrderId = "lsp-order-id", + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenAnswer { invocation -> + val transfer = invocation.getArgument(0) + val channels = invocation.getArgument>(1) + channels.find { it.fundingTxo?.txid == transfer.fundingTxId }?.channelId + } + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(amountSats, balanceState.balanceInTransferToSpending) + assertEquals(0uL, balanceState.totalLightningSats) + } + @Test fun `should not count manual channel as pending when ready`() = test { newBalanceDetails() @@ -522,13 +566,14 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { type: TransferType, amountSats: Long, channelId: String? = null, + fundingTxId: String? = null, lspOrderId: String? = null, ) = TransferEntity( id = "test-transfer-${System.currentTimeMillis()}", type = type, amountSats = amountSats, channelId = channelId, - fundingTxId = null, + fundingTxId = fundingTxId, lspOrderId = lspOrderId, isSettled = false, createdAt = System.currentTimeMillis(), diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 7f39eaf94..c1cc82ea6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -33,6 +33,8 @@ import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.HwFundingBroadcastResult +import to.bitkit.repositories.HwFundingTransaction import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState @@ -169,22 +171,37 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = 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 broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) - whenever(hwWalletRepo.reconnect(DEVICE_ID, forceSession = true)) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.success(mock())) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.signAndBroadcastFunding(any(), any(), any(), any())).thenReturn(Result.success(TXID)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() - verify(hwWalletRepo).signAndBroadcastFunding( + verify(hwWalletRepo).composeFundingTransaction( eq(DEVICE_ID), eq(order.payment?.onchain?.address.orEmpty()), eq(order.feeSat), eq(FEE_RATE), ) + verify(hwWalletRepo).signAndBroadcastFunding(eq(DEVICE_ID), eq(funding)) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(transferRepo).createTransfer( eq(TransferType.TO_SPENDING), @@ -197,10 +214,10 @@ class TransferViewModelTest : BaseUnitTest() { verify(transferRepo).createPendingToSpendingActivity( eq(order), eq(TXID), - eq(0uL), + eq(MINING_FEE), eq(FEE_RATE), ) - verify(hwWalletRepo).reconnect(DEVICE_ID, forceSession = true) + verify(hwWalletRepo).ensureConnected(DEVICE_ID) } @Test @@ -208,14 +225,15 @@ class TransferViewModelTest : BaseUnitTest() { val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) - whenever(hwWalletRepo.reconnect(DEVICE_ID, forceSession = true)) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) .thenReturn(Result.failure(RuntimeException("no device"))) sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) advanceUntilIdle() - verify(hwWalletRepo).reconnect(DEVICE_ID, forceSession = true) - verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any(), any(), any()) + verify(hwWalletRepo).ensureConnected(DEVICE_ID) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) } private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( @@ -254,5 +272,6 @@ class TransferViewModelTest : BaseUnitTest() { const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL + const val MINING_FEE = 1_250uL } } diff --git a/changelog.d/next/1039.added.md b/changelog.d/next/1039.added.md index 4d28eedb4..37e39e075 100644 --- a/changelog.d/next/1039.added.md +++ b/changelog.d/next/1039.added.md @@ -1 +1 @@ -Transfer funds from a paired Trezor hardware wallet to your spending balance, with the funding transaction signed on the device. +Transfer funds from a paired Trezor hardware wallet to your spending balance, with safer fee handling and confirmation tracking for device-signed funding transactions. From d0865f6eb41b8775070d1c88d4502a2e0ce75061 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 18:51:45 +0200 Subject: [PATCH 20/29] refactor: move hw funding models --- app/src/main/java/to/bitkit/models/HwFunding.kt | 16 ++++++++++++++++ .../java/to/bitkit/repositories/HwWalletRepo.kt | 17 ++--------------- .../to/bitkit/viewmodels/TransferViewModel.kt | 4 ++-- .../to/bitkit/repositories/HwWalletRepoTest.kt | 1 + .../bitkit/viewmodels/TransferViewModelTest.kt | 4 ++-- 5 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/HwFunding.kt diff --git a/app/src/main/java/to/bitkit/models/HwFunding.kt b/app/src/main/java/to/bitkit/models/HwFunding.kt new file mode 100644 index 000000000..36661ed79 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HwFunding.kt @@ -0,0 +1,16 @@ +package to.bitkit.models + +data class HwFundingTransaction( + val psbt: String, + val miningFeeSats: ULong, + val feeRate: Float, + val totalSpent: ULong, + val satsPerVByte: ULong, +) + +data class HwFundingBroadcastResult( + val txId: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 33a098919..3e3050472 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -42,6 +42,8 @@ import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice @@ -566,18 +568,3 @@ private data class HwWatcherData( val transactions: ImmutableList, val activities: ImmutableList, ) - -data class HwFundingTransaction( - val psbt: String, - val miningFeeSats: ULong, - val feeRate: Float, - val totalSpent: ULong, - val satsPerVByte: ULong, -) - -data class HwFundingBroadcastResult( - val txId: String, - val miningFeeSats: ULong, - val feeRate: ULong, - val totalSpent: ULong, -) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 1d04ca9a1..bd30ed603 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -32,13 +32,13 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.amountOnClose +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo -import to.bitkit.repositories.HwFundingBroadcastResult -import to.bitkit.repositories.HwFundingTransaction import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 9d6fddc62..371a9240a 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -32,6 +32,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.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index c1cc82ea6..dee3b9f7c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -28,13 +28,13 @@ import to.bitkit.data.SettingsStore import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState -import to.bitkit.repositories.HwFundingBroadcastResult -import to.bitkit.repositories.HwFundingTransaction import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState From cb1dc2969ef6bd2c1c59a27d7b762fd84fe948d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 19:20:26 +0200 Subject: [PATCH 21/29] fix: harden trezor bridge signing --- .../bitkit/services/TrezorBridgeTransport.kt | 7 ++- .../services/TrezorBridgeTransportTest.kt | 41 ++++++++++++++++- journeys/hardware-wallet/README.md | 9 ++++ .../transfer-to-spending-max-lsp-cap.xml | 44 +++++++++++++++++++ .../transfer-to-spending-node-warmup.xml | 37 ++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml create mode 100644 journeys/hardware-wallet/transfer-to-spending-node-warmup.xml diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 27d246ae0..e0fdccb58 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -24,6 +24,8 @@ import javax.inject.Singleton class TrezorBridgeTransport( private val baseUrl: String, private val enabled: Boolean, + private val readTimeoutMs: Int = READ_TIMEOUT_MS, + private val callReadTimeoutMs: Int = CALL_READ_TIMEOUT_MS, ) { @Inject constructor() : this( @@ -40,6 +42,7 @@ class TrezorBridgeTransport( private const val HEADER_SIZE = 6 private const val CONNECT_TIMEOUT_MS = 5_000 private const val READ_TIMEOUT_MS = 30_000 + private const val CALL_READ_TIMEOUT_MS = 120_000 } private val json = Json { ignoreUnknownKeys = true } @@ -139,7 +142,7 @@ class TrezorBridgeTransport( return runCatching { val request = encodeFrame(messageType, data) - val response = post("/call/${encode(session)}", request) + val response = post("/call/${encode(session)}", request, readTimeoutMs = callReadTimeoutMs) decodeFrame(response) }.getOrElse { Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG) @@ -182,7 +185,7 @@ class TrezorBridgeTransport( ) } - private fun post(path: String, body: String? = null): String { + private fun post(path: String, body: String? = null, readTimeoutMs: Int = this.readTimeoutMs): String { val connection = URL("$bridgeUrl$path").openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.connectTimeout = CONNECT_TIMEOUT_MS diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 044b16c05..c458380ad 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -134,10 +134,47 @@ class TrezorBridgeTransportTest { assertTrue(result.error.contains("shorter")) } - private fun createSut(enabled: Boolean = true): TrezorBridgeTransport { + @Test + fun `call uses longer read timeout than bridge management requests`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 55u, byteArrayOf()) + + assertTrue(result.success) + assertEquals(18u.toUShort(), result.messageType) + assertEquals(listOf(0x01), result.data.toList()) + } + + private fun createSut( + enabled: Boolean = true, + readTimeoutMs: Int = 30_000, + callReadTimeoutMs: Int = 120_000, + ): TrezorBridgeTransport { return TrezorBridgeTransport( baseUrl = "http://127.0.0.1:${server.port}", enabled = enabled, + readTimeoutMs = readTimeoutMs, + callReadTimeoutMs = callReadTimeoutMs, ) } @@ -159,6 +196,7 @@ class TrezorBridgeTransportTest { private data class TestHttpResponse( val body: String, val statusCode: Int = 200, + val delayMs: Long = 0L, ) private class TestHttpServer { @@ -213,6 +251,7 @@ class TrezorBridgeTransportTest { ) requests.add("${request.method} ${request.path}") val response = route(request) + if (response.delayMs > 0) Thread.sleep(response.delayMs) val responseBytes = response.body.toByteArray() val responseHeaders = ( "HTTP/1.1 ${response.statusCode} OK\r\n" + diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 782a71682..b823c0b83 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -66,6 +66,9 @@ Remove step forgets the device. | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | +| `transfer-to-spending.xml` | Happy-path transfer amount → sign → processing flow with a valid amount below the cap | +| `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | +| `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, `HardwareWalletIntroCancel`, `HardwareWalletIntroContinue`, @@ -94,3 +97,9 @@ To exercise the received-money sheet (not covered by a journey because it needs out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, e.g. send to an address generated via Dev Settings → Trezor → Get Address, then mine a block with `./bitcoin-cli`. + +For transfer-to-spending QA, explicitly cover the LSP cap boundary: the hardware wallet +balance can be much larger than the displayed AVAILABLE amount because MAX is capped by +Blocktank channel headroom. After signing, decode the funding transaction and compare the +activity DB row: the on-chain activity fee should be the composed mining fee, while the +funding output should equal the final Blocktank `order.feeSat`. diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml new file mode 100644 index 000000000..a36e8485d --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -0,0 +1,44 @@ + + + Verifies that a funded hardware wallet whose balance is larger than the current + Blocktank/LSP headroom is capped by the transferable spending limit, not by the + device balance. Requires a paired Bridge emulator or physical Trezor with a hardware + balance larger than the available transfer limit, and at least one existing channel or + pending order consuming most of the regtest LSP cap. + + + + Launch the Bitkit app and go to the wallet home screen + + + Verify the hardware wallet tile shows a balance larger than the AVAILABLE amount expected in the transfer flow + + + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom + + + Tap the "MAX" quick button (testTag "HardwareTransferAmountMax") + + + Verify the amount field matches the AVAILABLE amount exactly and does not use the full hardware wallet balance + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + + + Verify the sign screen opens (testTag "HardwareTransferSign"), showing NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL, without an insufficient-funds toast + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and approve every hardware-wallet prompt + + + Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress + + + diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml new file mode 100644 index 000000000..518131da3 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -0,0 +1,37 @@ + + + Verifies that starting a hardware-wallet transfer while the Lightning node is still + warming up does not fail or strand the CTA in loading. The flow should show the normal + loading/progress UI, continue once the node reaches the needed state, and land on the + sign screen. Requires a paired and funded hardware wallet. + + + + adb: adb shell am force-stop to.bitkit.dev + + + adb: adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 + + + As soon as the wallet home screen is visible, tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up + + + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") if the amount screen is shown, then wait for order creation and node warm-up to finish + + + Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", and the Continue/Open Trezor Connect CTA is no longer loading + + + Navigate back without signing so this journey only covers node warm-up behavior + + + From 4673701c17220769a5613c101c9497aaf23f2393 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 23 Jun 2026 21:01:37 +0200 Subject: [PATCH 22/29] refactor: aggregate hw wallet models --- .../java/to/bitkit/models/HardwareWallet.kt | 97 +++++++++++++++++++ .../main/java/to/bitkit/models/HwFunding.kt | 16 --- .../java/to/bitkit/models/HwFundingAccount.kt | 45 --------- .../main/java/to/bitkit/models/HwWallet.kt | 39 -------- 4 files changed, 97 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/HardwareWallet.kt delete mode 100644 app/src/main/java/to/bitkit/models/HwFunding.kt delete mode 100644 app/src/main/java/to/bitkit/models/HwFundingAccount.kt delete mode 100644 app/src/main/java/to/bitkit/models/HwWallet.kt diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt new file mode 100644 index 000000000..ea8471799 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -0,0 +1,97 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.serialization.Serializable + +/** A paired hardware wallet tracked as a watch-only balance. */ +@Stable +data class HwWallet( + val id: String, + val name: String, + val model: String?, + val transportType: TransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, + val deviceIds: ImmutableSet = persistentSetOf(id), +) + +/** Serializable per-device balance snapshot carried by [BalanceState]. */ +@Immutable +@Serializable +data class HwWalletBalance( + val id: String, + val sats: ULong, +) + +/** A newly detected inbound transaction to a watched hardware wallet. */ +@Immutable +data class HwWalletReceivedTx( + val txid: String, + val sats: ULong, +) + +sealed interface HwFundingAccount { + val vendor: HwWalletVendor + val xpub: String + val addressType: HwFundingAddressType + val accountType: AccountType + val balanceSats: ULong + + data class Trezor( + override val xpub: String, + override val addressType: HwFundingAddressType, + override val balanceSats: ULong, + ) : HwFundingAccount { + override val vendor: HwWalletVendor = HwWalletVendor.TREZOR + override val accountType: AccountType + get() = addressType.accountType + } +} + +data class HwFundingTransaction( + val psbt: String, + val miningFeeSats: ULong, + val feeRate: Float, + val totalSpent: ULong, + val satsPerVByte: ULong, +) + +data class HwFundingBroadcastResult( + val txId: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) + +enum class HwWalletVendor { + TREZOR, +} + +enum class HwFundingAddressType( + val addressType: AddressType, +) { + LEGACY(AddressType.P2PKH), + NESTED_SEGWIT(AddressType.P2SH), + NATIVE_SEGWIT(AddressType.P2WPKH), + TAPROOT(AddressType.P2TR); + + val settingsKey: String + get() = addressType.toSettingsString() + + val accountType: AccountType + get() = addressType.toAccountType() + + companion object { + val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE } + } +} + +fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/models/HwFunding.kt b/app/src/main/java/to/bitkit/models/HwFunding.kt deleted file mode 100644 index 36661ed79..000000000 --- a/app/src/main/java/to/bitkit/models/HwFunding.kt +++ /dev/null @@ -1,16 +0,0 @@ -package to.bitkit.models - -data class HwFundingTransaction( - val psbt: String, - val miningFeeSats: ULong, - val feeRate: Float, - val totalSpent: ULong, - val satsPerVByte: ULong, -) - -data class HwFundingBroadcastResult( - val txId: String, - val miningFeeSats: ULong, - val feeRate: ULong, - val totalSpent: ULong, -) diff --git a/app/src/main/java/to/bitkit/models/HwFundingAccount.kt b/app/src/main/java/to/bitkit/models/HwFundingAccount.kt deleted file mode 100644 index 07ef6bca0..000000000 --- a/app/src/main/java/to/bitkit/models/HwFundingAccount.kt +++ /dev/null @@ -1,45 +0,0 @@ -package to.bitkit.models - -import com.synonym.bitkitcore.AccountType -import com.synonym.bitkitcore.AddressType - -sealed interface HwFundingAccount { - val vendor: HwWalletVendor - val xpub: String - val addressType: HwFundingAddressType - val accountType: AccountType - val balanceSats: ULong - - data class Trezor( - override val xpub: String, - override val addressType: HwFundingAddressType, - override val balanceSats: ULong, - ) : HwFundingAccount { - override val vendor: HwWalletVendor = HwWalletVendor.TREZOR - override val accountType: AccountType - get() = addressType.accountType - } -} - -enum class HwWalletVendor { - TREZOR, -} - -enum class HwFundingAddressType( - val addressType: AddressType, -) { - LEGACY(AddressType.P2PKH), - NESTED_SEGWIT(AddressType.P2SH), - NATIVE_SEGWIT(AddressType.P2WPKH), - TAPROOT(AddressType.P2TR); - - val settingsKey: String - get() = addressType.toSettingsString() - - val accountType: AccountType - get() = addressType.toAccountType() - - companion object { - val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE } - } -} diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt deleted file mode 100644 index fc31bb78f..000000000 --- a/app/src/main/java/to/bitkit/models/HwWallet.kt +++ /dev/null @@ -1,39 +0,0 @@ -package to.bitkit.models - -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.synonym.bitkitcore.Activity -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.serialization.Serializable - -/** A paired hardware wallet tracked as a watch-only balance. */ -@Stable -data class HwWallet( - val id: String, - val name: String, - val model: String?, - val transportType: TransportType, - val isConnected: Boolean, - val balanceSats: ULong, - val activities: ImmutableList, - val deviceIds: ImmutableSet = persistentSetOf(id), -) - -/** Serializable per-device balance snapshot carried by [BalanceState]. */ -@Immutable -@Serializable -data class HwWalletBalance( - val id: String, - val sats: ULong, -) - -/** A newly detected inbound transaction to a watched hardware wallet. */ -@Immutable -data class HwWalletReceivedTx( - val txid: String, - val sats: ULong, -) - -fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) From 56aa8f116b5acee0f52192198a10bec34d4b40d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 03:29:20 +0200 Subject: [PATCH 23/29] fix: harden hw transfer edge cases --- .../java/to/bitkit/models/HardwareWallet.kt | 1 + .../to/bitkit/repositories/HwWalletRepo.kt | 22 ++++- .../java/to/bitkit/repositories/TrezorRepo.kt | 3 +- .../bitkit/services/TrezorBridgeTransport.kt | 11 ++- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../screens/wallets/HardwareWalletScreen.kt | 17 ++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 31 ++++++- .../bitkit/repositories/HwWalletRepoTest.kt | 9 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 20 ++++ .../services/TrezorBridgeTransportTest.kt | 33 ++++++- .../viewmodels/TransferViewModelTest.kt | 93 +++++++++++++++++++ 11 files changed, 216 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt index ea8471799..c0d19e1a0 100644 --- a/app/src/main/java/to/bitkit/models/HardwareWallet.kt +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -20,6 +20,7 @@ data class HwWallet( val isConnected: Boolean, val balanceSats: ULong, val activities: ImmutableList, + val fundingBalanceSats: ULong = balanceSats, val deviceIds: ImmutableSet = persistentSetOf(id), ) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 3e3050472..f4992921c 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -57,6 +57,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.ceil import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -164,11 +165,12 @@ class HwWalletRepo @Inject constructor( "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" } val balanceSats = _watcherData.value - .filterKeys { key -> - key.substringAfter(WATCHER_ID_SEPARATOR) == addressType.settingsKey && - key.toDeviceId() in groupIds + .values + .filter { + it.addressType == addressType.settingsKey && + it.deviceId in groupIds } - .values.fold(0uL) { acc, watcher -> acc + watcher.balanceSats } + .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwFundingAccount.Trezor( xpub = xpub, addressType = addressType, @@ -229,12 +231,14 @@ class HwWalletRepo @Inject constructor( HwFundingBroadcastResult( txId = txId, miningFeeSats = funding.miningFeeSats, - feeRate = funding.satsPerVByte, + feeRate = ceil(funding.feeRate.toDouble()).toULong(), totalSpent = funding.totalSpent, ) } } + suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) + /** * Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the * same wallet identity so the same device paired over both transports renames consistently. @@ -295,6 +299,9 @@ class HwWalletRepo @Inject constructor( val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } val ids = devices.map { it.id }.toSet() val deviceWatchers = watcherData.values.filter { it.deviceId in ids } + val fundingBalanceSats = deviceWatchers + .filter { it.addressType == HwFundingAddressType.DEFAULT.settingsKey } + .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwWallet( id = device.id, name = device.displayName, @@ -305,6 +312,7 @@ class HwWalletRepo @Inject constructor( activities = deviceWatchers .toMergedActivities() .toImmutableList(), + fundingBalanceSats = fundingBalanceSats, deviceIds = ids.toImmutableSet(), ) } @@ -349,6 +357,7 @@ class HwWalletRepo @Inject constructor( .toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), + addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, transactions = event.transactions.toImmutableList(), activities = activities, @@ -532,6 +541,8 @@ class HwWalletRepo @Inject constructor( } private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) + + private fun String.toAddressTypeKey(): String = substringAfter(WATCHER_ID_SEPARATOR) } private data class WatcherSettings( @@ -564,6 +575,7 @@ private val KnownDevice.displayName: String private data class HwWatcherData( val deviceId: String, + val addressType: String, val balanceSats: ULong, val transactions: ImmutableList, val activities: ImmutableList, diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 41ab8f57f..1b344ac5b 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -180,8 +180,7 @@ class TrezorRepo @Inject constructor( .distinctBy { it.id } if (_state.value.connected != null) { - runCatching { trezorService.disconnect() } - .onFailure { Logger.warn("Failed to disconnect Trezor while resetting", it, context = TAG) } + runSuspendCatching { disconnect().getOrThrow() } } knownDevices.forEach { device -> diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index e0fdccb58..023045080 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -43,6 +43,12 @@ class TrezorBridgeTransport( private const val CONNECT_TIMEOUT_MS = 5_000 private const val READ_TIMEOUT_MS = 30_000 private const val CALL_READ_TIMEOUT_MS = 120_000 + + /** + * Trezor protobuf MessageType_SignTx. This is the only call that waits + * for on-device signing. + */ + private const val SIGN_TX_MESSAGE_TYPE = 15 } private val json = Json { ignoreUnknownKeys = true } @@ -142,7 +148,8 @@ class TrezorBridgeTransport( return runCatching { val request = encodeFrame(messageType, data) - val response = post("/call/${encode(session)}", request, readTimeoutMs = callReadTimeoutMs) + val timeoutMs = if (messageType == SIGN_TX_MESSAGE_TYPE.toUShort()) callReadTimeoutMs else readTimeoutMs + val response = post("/call/${encode(session)}", request, readTimeoutMs = timeoutMs) decodeFrame(response) }.getOrElse { Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG) @@ -189,7 +196,7 @@ class TrezorBridgeTransport( val connection = URL("$bridgeUrl$path").openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.connectTimeout = CONNECT_TIMEOUT_MS - connection.readTimeout = READ_TIMEOUT_MS + connection.readTimeout = readTimeoutMs connection.doInput = true if (body != null) { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index dc8deebf1..b79321d40 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1035,7 +1035,9 @@ private fun NavGraphBuilder.home( HardwareWalletScreen( deviceId = deviceId, onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, - onTransferToSpendingClick = { navController.navigateTo(Routes.SpendingAmountHw(deviceId)) }, + onTransferToSpendingClick = { selectedDeviceId -> + navController.navigateTo(Routes.SpendingAmountHw(selectedDeviceId)) + }, onBackClick = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index 3a1a67437..9b1d45116 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -63,7 +63,7 @@ import to.bitkit.ui.theme.TopBarGradient fun HardwareWalletScreen( deviceId: String, onActivityItemClick: (String) -> Unit, - onTransferToSpendingClick: () -> Unit, + onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), ) { @@ -96,13 +96,14 @@ private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, onActivityItemClick: (String) -> Unit, - onTransferToSpendingClick: () -> Unit, + onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, onDismissRemoveDialog: () -> Unit, onBackClick: () -> Unit, ) { val hasFunds = wallet.balanceSats > 0uL + val hasFundingFunds = wallet.fundingBalanceSats > 0uL val hasActivity = wallet.activities.isNotEmpty() val showEmptyState = !hasFunds && !hasActivity @@ -169,10 +170,10 @@ private fun HardwareWalletContent( if (!showEmptyState) { item { VerticalSpacer(32.dp) } - if (hasFunds) { + if (hasFundingFunds) { item { SecondaryButton( - onClick = onTransferToSpendingClick, + onClick = { onTransferToSpendingClick(wallet.id) }, text = stringResource(R.string.wallet__transfer_to_spending), icon = { Icon( @@ -264,7 +265,7 @@ private fun Preview() { wallet = previewWallet(), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -284,7 +285,7 @@ private fun PreviewNoActivity() { wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -304,7 +305,7 @@ private fun PreviewEmpty() { wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -324,7 +325,7 @@ private fun PreviewRemoveDialog() { wallet = previewWallet(), showRemoveDialog = true, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index bd30ed603..6efbbb0e5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -479,7 +479,7 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() updateTransferValues(0uL) - val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve().safe() + val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -592,7 +592,10 @@ class TransferViewModel @Inject constructor( } }.getOrElse { if (it is CancellationException && it !is TimeoutCancellationException) throw it - if (it is TimeoutCancellationException) throw HardwareSigningTimeoutError(it) + if (it is TimeoutCancellationException) { + hwWalletRepo.disconnectStaleSession(deviceId) + throw HardwareSigningTimeoutError(it) + } throw it } } @@ -642,11 +645,23 @@ class TransferViewModel @Inject constructor( ) } - private suspend fun hwFundingFeeReserve(): ULong = hwFundingSatsPerVByte().safe() * HW_FUNDING_TX_VBYTES.safe() + private suspend fun hwFundingFeeReserve(balanceSats: ULong): ULong { + val satsPerVByte = fetchHwFundingSatsPerVByte().getOrNull() + ?: return hwFundingFallbackFeeReserve(balanceSats) + return satsPerVByte.safe() * HW_FUNDING_TX_VBYTES.safe() + } + + private fun hwFundingFallbackFeeReserve(balanceSats: ULong): ULong { + val minReserve = HW_FUNDING_FALLBACK_SATS_PER_VBYTE.safe() * HW_FUNDING_TX_VBYTES.safe() + return maxOf(minReserve, balanceSats / HW_FUNDING_FEE_FALLBACK_DIVISOR) + } - private suspend fun hwFundingSatsPerVByte(): ULong { + private suspend fun hwFundingSatsPerVByte(): ULong = + fetchHwFundingSatsPerVByte().getOrDefault(HW_FUNDING_FALLBACK_SATS_PER_VBYTE) + + private suspend fun fetchHwFundingSatsPerVByte(): Result { val speed = settingsStore.data.first().defaultTransactionSpeed - return lightningRepo.getFeeRateForSpeed(speed).getOrNull() ?: 0uL + return lightningRepo.getFeeRateForSpeed(speed) } // endregion @@ -859,6 +874,12 @@ class TransferViewModel @Inject constructor( /** Conservative vbyte reserve for multi-input hardware funding before exact compose runs. */ private const val HW_FUNDING_TX_VBYTES = 1_200uL + /** Minimum fallback fee rate when fee estimates are temporarily unavailable. */ + private const val HW_FUNDING_FALLBACK_SATS_PER_VBYTE = 1uL + + /** Reserves 10% of the account when fee estimates are temporarily unavailable. */ + private const val HW_FUNDING_FEE_FALLBACK_DIVISOR = 10uL + /** Upper bound for reconnecting a known device before the UI asks for reconnect. */ private val HW_RECONNECT_TIMEOUT = 30.seconds diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 371a9240a..50cf626b1 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -184,7 +184,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - assertEquals(150uL, sut.wallets.value.single().balanceSats) + val wallet = sut.wallets.value.single() + assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.fundingBalanceSats) assertEquals(150uL, sut.totalSats.value) } @@ -527,6 +529,7 @@ class HwWalletRepoTest : BaseUnitTest() { val wallet = sut.wallets.value.single() assertEquals(421_900uL, wallet.balanceSats) + assertEquals(421_900uL, wallet.fundingBalanceSats) assertEquals(421_900uL, sut.totalSats.value) assertEquals(1, wallet.activities.size) assertEquals(setOf("ble1", "usb1"), wallet.deviceIds) @@ -794,7 +797,7 @@ class HwWalletRepoTest : BaseUnitTest() { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, - feeRate = 2.0f, + feeRate = 3.0f, totalSpent = 26_250uL, satsPerVByte = 2uL, ) @@ -808,7 +811,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isSuccess) assertEquals("broadcast-txid", result.getOrThrow().txId) assertEquals(1_250uL, result.getOrThrow().miningFeeSats) - assertEquals(2uL, result.getOrThrow().feeRate) + assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index dd9728cbc..ee85447b6 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -827,6 +827,26 @@ class TrezorRepoTest : BaseUnitTest() { verify(hwWalletStore).reset() } + @Test + fun `resetState disconnects connected transport session`() = test { + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + sut.scan() + sut.connect(DEVICE_ID) + sut.resetState() + + verify(trezorService).disconnect() + verify(trezorTransport).disconnectDevice(DEVICE_PATH) + assertNull(sut.state.value.connectedDevice()) + } + @Test fun `resetState clears initialized setup gate`() = test { val devices = listOf(mockDeviceInfo()) diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index c458380ad..254089fb7 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -135,7 +135,7 @@ class TrezorBridgeTransportTest { } @Test - fun `call uses longer read timeout than bridge management requests`() { + fun `signing call uses longer read timeout than bridge management requests`() { server.route = { request -> when { request.path == "/enumerate" -> { @@ -158,13 +158,42 @@ class TrezorBridgeTransportTest { val device = sut.enumerateDevices().single() assertTrue(sut.openDevice(device.path).success) - val result = sut.callMessage(device.path, 55u, byteArrayOf()) + val result = sut.callMessage(device.path, 15u, byteArrayOf()) assertTrue(result.success) assertEquals(18u.toUShort(), result.messageType) assertEquals(listOf(0x01), result.data.toList()) } + @Test + fun `non signing call uses bridge management read timeout`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 55u, byteArrayOf()) + + assertFalse(result.success) + } + private fun createSut( enabled: Boolean = true, readTimeoutMs: Int = 30_000, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index dee3b9f7c..36dd53b23 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -9,8 +9,10 @@ import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeStatus @@ -42,6 +44,7 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.time.Clock @@ -168,6 +171,31 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) } + @Test + fun `updateHwLimits reserves fallback fee when fee rate lookup fails`() = test { + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + .thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = ON_CHAIN_BALANCE, + ), + ), + ) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("fee unavailable"))) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateHwLimits(DEVICE_ID) + advanceUntilIdle() + + verify(blocktankRepo).estimateOrderFee(eq(HW_AVAILABLE_WITH_FALLBACK_RESERVE), any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { val order = previewBtOrder() @@ -220,6 +248,42 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(DEVICE_ID) } + @Test + fun `onTransferToSpendingHwConfirm composes with fallback fee rate when fee lookup fails`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FALLBACK_FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FALLBACK_FEE_RATE, + ) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FALLBACK_FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + 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.failure(AppError("fee unavailable"))) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).composeFundingTransaction( + eq(DEVICE_ID), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(FALLBACK_FEE_RATE), + ) + } + @Test fun `onTransferToSpendingHwConfirm aborts when hardware reconnect fails`() = test { val order = previewBtOrder() @@ -236,6 +300,33 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) } + @Test + fun `onTransferToSpendingHwConfirm disconnects stale session when signing times out`() = test { + val order = previewBtOrder() + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + 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.failure(timeout)) + whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( id = deviceId, name = "Trezor", @@ -272,6 +363,8 @@ class TransferViewModelTest : BaseUnitTest() { const val XPUB = "zpub-test" const val TXID = "tx-abc" const val FEE_RATE = 2uL + const val FALLBACK_FEE_RATE = 1uL const val MINING_FEE = 1_250uL + const val HW_AVAILABLE_WITH_FALLBACK_RESERVE = 9_000_000uL } } From 0ee223dfbbf49e6e7fbb0f6bbb4c0d1c98a9ba5d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 04:50:44 +0200 Subject: [PATCH 24/29] refactor: reuse fee fallback constant --- .../main/java/to/bitkit/viewmodels/TransferViewModel.kt | 7 +++---- .../java/to/bitkit/viewmodels/TransferViewModelTest.kt | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 6efbbb0e5..7dad80d0f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -44,6 +44,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -653,7 +654,8 @@ class TransferViewModel @Inject constructor( private fun hwFundingFallbackFeeReserve(balanceSats: ULong): ULong { val minReserve = HW_FUNDING_FALLBACK_SATS_PER_VBYTE.safe() * HW_FUNDING_TX_VBYTES.safe() - return maxOf(minReserve, balanceSats / HW_FUNDING_FEE_FALLBACK_DIVISOR) + val fallback = (balanceSats.toDouble() * DeriveBalanceStateUseCase.FALLBACK_FEE_PERCENT).toULong() + return maxOf(minReserve, fallback) } private suspend fun hwFundingSatsPerVByte(): ULong = @@ -877,9 +879,6 @@ class TransferViewModel @Inject constructor( /** Minimum fallback fee rate when fee estimates are temporarily unavailable. */ private const val HW_FUNDING_FALLBACK_SATS_PER_VBYTE = 1uL - /** Reserves 10% of the account when fee estimates are temporarily unavailable. */ - private const val HW_FUNDING_FEE_FALLBACK_DIVISOR = 10uL - /** Upper bound for reconnecting a known device before the UI asks for reconnect. */ private val HW_RECONNECT_TIMEOUT = 30.seconds diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 36dd53b23..32c6db3d1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -35,6 +35,7 @@ import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.HwWalletRepo @@ -44,6 +45,7 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals @@ -193,7 +195,8 @@ class TransferViewModelTest : BaseUnitTest() { sut.updateHwLimits(DEVICE_ID) advanceUntilIdle() - verify(blocktankRepo).estimateOrderFee(eq(HW_AVAILABLE_WITH_FALLBACK_RESERVE), any(), any()) + val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * DeriveBalanceStateUseCase.FALLBACK_FEE_PERCENT).toULong() + verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE.safe() - fallbackReserve.safe()), any(), any()) } @Test @@ -365,6 +368,5 @@ class TransferViewModelTest : BaseUnitTest() { const val FEE_RATE = 2uL const val FALLBACK_FEE_RATE = 1uL const val MINING_FEE = 1_250uL - const val HW_AVAILABLE_WITH_FALLBACK_RESERVE = 9_000_000uL } } From 5778f44a88f335ff6a24c0d95577f14d9b68c3b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 04:56:51 +0200 Subject: [PATCH 25/29] refactor: move fee fallback default --- app/src/main/java/to/bitkit/env/Env.kt | 3 +++ .../main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt | 3 ++- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 4 ++-- .../test/java/to/bitkit/viewmodels/TransferViewModelTest.kt | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 5478a4845..0b550823a 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -263,6 +263,9 @@ object Defaults { /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u + /** Fallback fee percentage used when fee estimates are temporarily unavailable. */ + const val fallbackFeePercent = 0.1 + /** * Minimum value in sats for an output. Outputs below the dust limit may not be processed because the fees * required to include them in a block would be greater than the value of the transaction itself. diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 238efb2c3..c958cd837 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -8,6 +8,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -173,6 +174,6 @@ class DeriveBalanceStateUseCase @Inject constructor( companion object { const val TAG = "DeriveBalanceStateUseCase" - const val FALLBACK_FEE_PERCENT = 0.1 + const val FALLBACK_FEE_PERCENT = Defaults.fallbackFeePercent } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 7dad80d0f..38116c390 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -31,6 +31,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose import to.bitkit.models.HwFundingBroadcastResult import to.bitkit.models.HwFundingTransaction @@ -44,7 +45,6 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -654,7 +654,7 @@ class TransferViewModel @Inject constructor( private fun hwFundingFallbackFeeReserve(balanceSats: ULong): ULong { val minReserve = HW_FUNDING_FALLBACK_SATS_PER_VBYTE.safe() * HW_FUNDING_TX_VBYTES.safe() - val fallback = (balanceSats.toDouble() * DeriveBalanceStateUseCase.FALLBACK_FEE_PERCENT).toULong() + val fallback = (balanceSats.toDouble() * Defaults.fallbackFeePercent).toULong() return maxOf(minReserve, fallback) } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 32c6db3d1..f6bc83f01 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -27,6 +27,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType @@ -45,7 +46,6 @@ import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder -import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals @@ -195,7 +195,7 @@ class TransferViewModelTest : BaseUnitTest() { sut.updateHwLimits(DEVICE_ID) advanceUntilIdle() - val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * DeriveBalanceStateUseCase.FALLBACK_FEE_PERCENT).toULong() + val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * Defaults.fallbackFeePercent).toULong() verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE.safe() - fallbackReserve.safe()), any(), any()) } From d9a5314a8961824b0c3538047637b3ae46075897 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 16:02:36 +0200 Subject: [PATCH 26/29] fix: show hw spending intro --- app/src/main/java/to/bitkit/ui/ContentView.kt | 70 ++++++++++++++----- .../test/java/to/bitkit/ui/ContentViewTest.kt | 20 ++++++ 2 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/ContentViewTest.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b79321d40..a3353f94c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -755,6 +755,16 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + SpendingIntroScreen( + onContinueClick = { + navController.navigateToTransferSpendingAmountHw(deviceId) + settingsViewModel.setHasSeenSpendingIntro(true) + }, + onBackClick = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountScreen( @@ -842,11 +852,7 @@ private fun RootNavHost( FundingScreen( onTransfer = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onFund = { scope.launch { @@ -991,11 +997,7 @@ private fun NavGraphBuilder.home( onActivityItemClick = { navController.navigateToActivityItem(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, @@ -1021,22 +1023,19 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onBackClick = { navController.popBackStack() }, ) } composableWithDefaultTransitions { val deviceId = it.toRoute().deviceId + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( deviceId = deviceId, onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, onTransferToSpendingClick = { selectedDeviceId -> - navController.navigateTo(Routes.SpendingAmountHw(selectedDeviceId)) + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, onBackClick = { navController.popBackStack() }, ) @@ -1829,6 +1828,42 @@ fun NavController.navigateToTransferSpendingIntro() = navigateTo(Routes.Spending fun NavController.navigateToTransferSpendingAmount() = navigateTo(Routes.SpendingAmount) +fun NavController.navigateToTransferSpendingIntroHw(deviceId: String) = navigateTo(Routes.SpendingIntroHw(deviceId)) + +fun NavController.navigateToTransferSpendingAmountHw(deviceId: String) = navigateTo(Routes.SpendingAmountHw(deviceId)) + +fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) { + when (transferSpendingStartRoute(hasSeenSpendingIntro)) { + Routes.SpendingIntro -> navigateToTransferSpendingIntro() + Routes.SpendingAmount -> navigateToTransferSpendingAmount() + else -> Unit + } +} + +fun NavController.navigateToTransferSpendingStart( + hasSeenSpendingIntro: Boolean, + deviceId: String, +) { + when (val route = transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) { + is Routes.SpendingIntroHw -> navigateToTransferSpendingIntroHw(route.deviceId) + is Routes.SpendingAmountHw -> navigateToTransferSpendingAmountHw(route.deviceId) + else -> Unit + } +} + +internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { + hasSeenSpendingIntro -> Routes.SpendingAmount + else -> Routes.SpendingIntro +} + +internal fun transferSpendingStartRoute( + hasSeenSpendingIntro: Boolean, + deviceId: String, +): Routes = when { + hasSeenSpendingIntro -> Routes.SpendingAmountHw(deviceId) + else -> Routes.SpendingIntroHw(deviceId) +} + fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) fun NavController.navigateToTransferFunding() = navigateTo(Routes.Funding) @@ -1989,6 +2024,9 @@ sealed interface Routes { @Serializable data object SpendingIntro : Routes + @Serializable + data class SpendingIntroHw(val deviceId: String) : Routes + @Serializable data object SpendingAmount : Routes diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt new file mode 100644 index 000000000..791ba0519 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -0,0 +1,20 @@ +package to.bitkit.ui + +import org.junit.Test +import kotlin.test.assertEquals + +class ContentViewTest { + @Test + fun `spending start route uses intro until seen`() { + assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false)) + assertEquals(Routes.SpendingAmount, transferSpendingStartRoute(hasSeenSpendingIntro = true)) + } + + @Test + fun `hardware spending start route keeps device id after intro`() { + val deviceId = "trezor-1" + + assertEquals(Routes.SpendingIntroHw(deviceId), transferSpendingStartRoute(false, deviceId)) + assertEquals(Routes.SpendingAmountHw(deviceId), transferSpendingStartRoute(true, deviceId)) + } +} From e8a503994471d612abc27607b83c0afab81de867 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 16:12:07 +0200 Subject: [PATCH 27/29] fix: handle hw signed effect --- .../to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index d91a05306..ad1f623cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -93,6 +93,7 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() + TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) @@ -106,8 +107,6 @@ fun SpendingAdvancedScreen( description = effect.description, ) } - - else -> Unit } } } From a5d4dcb6f353633b8aa7f389eb97351e64cdad90 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 17:24:08 +0200 Subject: [PATCH 28/29] refactor: simplify spending navigation --- app/src/main/java/to/bitkit/ui/ContentView.kt | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a3353f94c..d98a6e773 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -759,7 +759,7 @@ private fun RootNavHost( val deviceId = entry.toRoute().deviceId SpendingIntroScreen( onContinueClick = { - navController.navigateToTransferSpendingAmountHw(deviceId) + navController.navigateTo(Routes.SpendingAmountHw(deviceId)) settingsViewModel.setHasSeenSpendingIntro(true) }, onBackClick = { navController.popBackStack() }, @@ -1824,32 +1824,13 @@ fun NavController.navigateToTransferSavingsIntro() = navigateTo(Routes.SavingsIn fun NavController.navigateToTransferSavingsAvailability() = navigateTo(Routes.SavingsAvailability) -fun NavController.navigateToTransferSpendingIntro() = navigateTo(Routes.SpendingIntro) - -fun NavController.navigateToTransferSpendingAmount() = navigateTo(Routes.SpendingAmount) - -fun NavController.navigateToTransferSpendingIntroHw(deviceId: String) = navigateTo(Routes.SpendingIntroHw(deviceId)) - -fun NavController.navigateToTransferSpendingAmountHw(deviceId: String) = navigateTo(Routes.SpendingAmountHw(deviceId)) - -fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) { - when (transferSpendingStartRoute(hasSeenSpendingIntro)) { - Routes.SpendingIntro -> navigateToTransferSpendingIntro() - Routes.SpendingAmount -> navigateToTransferSpendingAmount() - else -> Unit - } -} +fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) = + navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro)) fun NavController.navigateToTransferSpendingStart( hasSeenSpendingIntro: Boolean, deviceId: String, -) { - when (val route = transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) { - is Routes.SpendingIntroHw -> navigateToTransferSpendingIntroHw(route.deviceId) - is Routes.SpendingAmountHw -> navigateToTransferSpendingAmountHw(route.deviceId) - else -> Unit - } -} +) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { hasSeenSpendingIntro -> Routes.SpendingAmount From 7583336930b7afe8b012a5b0fc6122d13e3355e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 18:09:03 +0200 Subject: [PATCH 29/29] fix: preserve reconnect cancellation --- .../java/to/bitkit/repositories/TrezorRepo.kt | 74 ++++++++++--------- .../to/bitkit/repositories/TrezorRepoTest.kt | 18 +++++ 2 files changed, 58 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 1b344ac5b..1bb77cf30 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -645,42 +645,48 @@ class TrezorRepo @Inject constructor( if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) } - runCatching { - _state.update { it.copy(isConnecting = true, error = null) } - Logger.debug("Started known-device reconnect for '$deviceId'", context = TAG) - Logger.debug("Awaiting setup for reconnect", context = TAG) - awaitSetup() - Logger.debug("Completed setup for reconnect", context = TAG) - if (forceSession) { - Logger.debug("Closing stale session before reconnect for '$deviceId'", context = TAG) - disconnectStaleSession(deviceId) + var startedConnecting = false + try { + runSuspendCatching { + startedConnecting = true + _state.update { it.copy(isConnecting = true, error = null) } + Logger.debug("Started known-device reconnect for '$deviceId'", context = TAG) + Logger.debug("Awaiting setup for reconnect", context = TAG) + awaitSetup() + Logger.debug("Completed setup for reconnect", context = TAG) + if (forceSession) { + Logger.debug("Closing stale session before reconnect for '$deviceId'", context = TAG) + disconnectStaleSession(deviceId) + } + Logger.debug("Scanning for reconnect devices", context = TAG) + val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + val knownDevice = knownDevices.find { it.matches(deviceId) } + val scannedDevices = trezorService.scan() + Logger.debug( + "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", + context = TAG, + ) + // Honor the transport the user selected — connect to exactly the + // entry they tapped instead of overriding Bluetooth with USB. + val device = scannedDevices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() + ?: throw AppError("Device not found nearby — is it powered on?") + Logger.debug("Found reconnect device '${device.id}'", context = TAG) + Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) + val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) + Logger.debug("Connected known device '${device.id}'", context = TAG) + addOrUpdateKnownDevice(device, features) + _state.update { it.copy(connected = ConnectedTrezorDevice(id = device.id, features = features)) } + Logger.info("Reconnected known device '${device.id}'", context = TAG) + features + }.onFailure { e -> + Logger.error("Connect known device failed", e, context = TAG) + _state.update { it.copy(error = e.message) } } - Logger.debug("Scanning for reconnect devices", context = TAG) - val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } - val knownDevice = knownDevices.find { it.matches(deviceId) } - val scannedDevices = trezorService.scan() - Logger.debug( - "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", - context = TAG, - ) - // Honor the transport the user selected — connect to exactly the - // entry they tapped instead of overriding Bluetooth with USB. - val device = scannedDevices.find { it.id == deviceId } - ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() - ?: throw AppError("Device not found nearby — is it powered on?") - Logger.debug("Found reconnect device '${device.id}'", context = TAG) - Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) - val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) - Logger.debug("Connected known device '${device.id}'", context = TAG) - addOrUpdateKnownDevice(device, features) - _state.update { - it.copy(isConnecting = false, connected = ConnectedTrezorDevice(id = device.id, features = features)) + } finally { + if (startedConnecting) { + _state.update { it.copy(isConnecting = false) } } - Logger.info("Reconnected known device '${device.id}'", context = TAG) - features - }.onFailure { e -> - Logger.error("Connect known device failed", e, context = TAG) - _state.update { it.copy(isConnecting = false, error = e.message) } } } diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index ee85447b6..9c0dc0f61 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -13,6 +13,7 @@ import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.TrezorTransportWriteResult import com.synonym.bitkitcore.WalletSelection +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -44,6 +45,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -1124,6 +1126,22 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService).connect(eq(bleDeviceId), any()) } + @Test + fun `connectKnownDevice should rethrow cancellation and clear connecting state`() = test { + val cancellation = CancellationException("cancelled") + whenever(trezorService.scan()).thenAnswer { throw cancellation } + sut = createSut() + + sut.initialize() + val thrown = assertFailsWith { + sut.connectKnownDevice(DEVICE_ID) + } + + assertEquals(cancellation.message, thrown.message) + assertFalse(sut.state.value.isConnecting) + assertNull(sut.state.value.error) + } + @Test fun `ensureConnected returns current selected device without reconnecting`() = test { val features = mockFeatures()