From 3182a9906c53adcbe5e4c0534b3ec954a2fcd48b Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 09:46:02 -0500 Subject: [PATCH 1/4] feat: add trezor send --- .../java/to/bitkit/ext/TrezorExceptionExt.kt | 24 + .../to/bitkit/repositories/ActivityRepo.kt | 73 ++- .../to/bitkit/repositories/HwWalletRepo.kt | 61 +- .../java/to/bitkit/repositories/TrezorRepo.kt | 74 ++- .../java/to/bitkit/services/CoreService.kt | 21 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 16 +- .../java/to/bitkit/ui/components/SheetHost.kt | 32 +- .../wallets/activity/ActivityDetailScreen.kt | 5 +- .../screens/wallets/send/HwSendSignScreen.kt | 164 ++++++ .../screens/wallets/send/HwSendViewModel.kt | 340 +++++++++++ .../screens/wallets/send/SendAmountScreen.kt | 26 +- .../screens/wallets/send/SendConfirmScreen.kt | 30 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 81 ++- .../viewmodels/ActivityDetailViewModel.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 546 ++++++++++++++---- .../to/bitkit/viewmodels/TransferViewModel.kt | 41 +- app/src/main/res/values/strings.xml | 7 +- .../to/bitkit/ext/TrezorExceptionExtTest.kt | 15 +- .../bitkit/repositories/ActivityRepoTest.kt | 2 +- .../bitkit/repositories/HwWalletRepoTest.kt | 60 +- .../to/bitkit/repositories/TrezorRepoTest.kt | 43 +- .../to/bitkit/services/CoreServiceTest.kt | 53 ++ .../wallets/send/HwSendViewModelTest.kt | 233 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 133 +++++ .../viewmodels/TransferViewModelTest.kt | 38 ++ changelog.d/next/1187.added.md | 1 + journeys/hardware-wallet/README.md | 8 +- journeys/hardware-wallet/send-onchain.xml | 60 ++ 28 files changed, 1991 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt create mode 100644 changelog.d/next/1187.added.md create mode 100644 journeys/hardware-wallet/send-onchain.xml diff --git a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt index 6b2bd5a2c7..2950295b28 100644 --- a/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt @@ -20,3 +20,27 @@ fun Throwable.isTrezorFirmwareError(): Boolean = val message = it.message.orEmpty() "Device error (code $FIRMWARE_ERROR_CODE)" in message && "Firmware error" in message } + +fun Throwable.isTrezorSessionFailure(): Boolean = + generateSequence(this) { it.cause }.any { error -> + when (error) { + is TrezorException.TransportException, + is TrezorException.DeviceDisconnected, + is TrezorException.ConnectionException, + is TrezorException.Timeout, + is TrezorException.NotConnected, + is TrezorException.SessionException, + is TrezorException.IoException, + -> true + + is TrezorException.ProtocolException -> error.errorDetails.lowercase().let { details -> + "thp decryption" in details || + "thp encryption" in details || + "thp ack" in details || + "thp invalid sync" in details || + "thp state missing" in details + } + + else -> false + } + } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index f5460b37a5..ac6f4ce4b6 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -347,11 +347,30 @@ class ActivityRepo @Inject constructor( type: ActivityFilter, txType: PaymentType?, retry: Boolean = true, + ): Result = findActivityByPaymentId( + paymentHashOrTxId = paymentHashOrTxId, + type = type, + txType = txType, + retry = retry, + walletId = WalletScope.default, + ) + + suspend fun findActivityByPaymentId( + paymentHashOrTxId: String, + type: ActivityFilter, + txType: PaymentType?, + retry: Boolean, + walletId: String, ): Result = withContext(bgDispatcher) { runCatching { require(paymentHashOrTxId.isNotEmpty()) { "paymentHashOrTxId is empty" } - suspend fun findActivity(): Activity? = getActivities(filter = type, txType = txType, limit = 10u) + suspend fun findActivity(): Activity? = getActivities( + walletId = walletId, + filter = type, + txType = txType, + limit = 10u, + ) .getOrNull() ?.firstOrNull { it.matchesPaymentId(paymentHashOrTxId) } @@ -362,13 +381,16 @@ class ActivityRepo @Inject constructor( context = TAG ) - lightningRepo.sync().onSuccess { Logger.debug("Syncing LN node SUCCESS", context = TAG) } - - syncActivities().onSuccess { - Logger.debug( - "Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'", - context = TAG, - ) + if (walletId == WalletScope.default) { + lightningRepo.sync().onSuccess { Logger.debug("Syncing LN node SUCCESS", context = TAG) } + syncActivities().onSuccess { + Logger.debug( + "Sync success, searching again the activity with paymentHashOrTxId:'$paymentHashOrTxId'", + context = TAG, + ) + activity = findActivity() + } + } else { activity = findActivity() } } @@ -376,7 +398,8 @@ class ActivityRepo @Inject constructor( checkNotNull(activity) { "Activity not found" } }.onFailure { Logger.error( - "findActivityByPaymentId error (paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType')", + "findActivityByPaymentId error " + + "(paymentHashOrTxId:'$paymentHashOrTxId' type:'$type' txType:'$txType' walletId:'$walletId')", context = TAG, ) } @@ -438,6 +461,7 @@ class ActivityRepo @Inject constructor( val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey val txIdsInBoostTxIds = getTxIdsInBoostTxIds() getActivities( + walletId = null, filter = ActivityFilter.ALL, sortDirection = SortDirection.DESC, ).getOrThrow() @@ -452,6 +476,7 @@ class ActivityRepo @Inject constructor( contactPublicKey: String, forPaymentId: String, syncLdkPayments: Boolean = true, + walletId: String = WalletScope.default, ): Result = withContext(ioDispatcher) { runCatching { if (syncLdkPayments) { @@ -461,7 +486,7 @@ class ActivityRepo @Inject constructor( } val normalizedKey = PubkyPublicKeyFormat.normalized(contactPublicKey) ?: contactPublicKey - val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments) + val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments, walletId) if (activity == null) { Logger.warn( "Skipped setting contact for payment '$forPaymentId' because activity was not found", @@ -476,7 +501,7 @@ class ActivityRepo @Inject constructor( val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() - updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt) + updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt, walletId) }.onFailure { Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG) } @@ -485,6 +510,7 @@ class ActivityRepo @Inject constructor( suspend fun clearContact( forPaymentId: String, syncLdkPayments: Boolean = true, + walletId: String = WalletScope.default, ): Result = withContext(ioDispatcher) { runCatching { if (syncLdkPayments) { @@ -493,7 +519,7 @@ class ActivityRepo @Inject constructor( }.getOrThrow() } - val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments) + val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments, walletId) if (activity == null) { Logger.warn( "Skipped clearing contact for payment '$forPaymentId' because activity was not found", @@ -506,7 +532,7 @@ class ActivityRepo @Inject constructor( val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(null, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() - updateReplacementContactIfNeeded(updatedActivity, null, updatedAt) + updateReplacementContactIfNeeded(updatedActivity, null, updatedAt, walletId) }.onFailure { Logger.error("Failed to clear contact for payment '$forPaymentId'", it, context = TAG) } @@ -516,10 +542,11 @@ class ActivityRepo @Inject constructor( activity: Activity, normalizedKey: String?, updatedAt: ULong, + walletId: String = WalletScope.default, ) { if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return - getActivities(filter = ActivityFilter.ONCHAIN).getOrThrow() + getActivities(walletId = walletId, filter = ActivityFilter.ONCHAIN).getOrThrow() .filterIsInstance() .filter { activity.v1.txId in it.v1.boostTxIds } .filterNot { PubkyPublicKeyFormat.matches(it.v1.contact, normalizedKey) } @@ -529,18 +556,24 @@ class ActivityRepo @Inject constructor( } } - private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? { - val activity = getActivityByPaymentId(forPaymentId) + private suspend fun findActivityForPaymentId( + forPaymentId: String, + syncLdkPayments: Boolean, + walletId: String = WalletScope.default, + ): Activity? { + val activity = getActivityByPaymentId(forPaymentId, walletId) if (activity != null) return activity if (!syncLdkPayments) return null syncActivities().getOrThrow() - return getActivityByPaymentId(forPaymentId) + return getActivityByPaymentId(forPaymentId, walletId) } - private suspend fun getActivityByPaymentId(forPaymentId: String): Activity? = - coreService.activity.getActivity(forPaymentId, WalletScope.default) - ?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) } + private suspend fun getActivityByPaymentId( + forPaymentId: String, + walletId: String = WalletScope.default, + ): Activity? = coreService.activity.getActivity(forPaymentId, walletId) + ?: getOnchainActivityByTxId(forPaymentId, walletId)?.let { Activity.Onchain(it) } private fun Activity.withContact(normalizedKey: String?, updatedAt: ULong): Activity = when (this) { is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 3cb350284c..8a4dd9f4e6 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -37,7 +37,7 @@ import to.bitkit.data.PendingNameUpdate import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env -import to.bitkit.ext.isTrezorUserCancellation +import to.bitkit.ext.isTrezorSessionFailure import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.scopedId import to.bitkit.ext.timestamp @@ -73,7 +73,7 @@ import kotlin.time.Duration.Companion.seconds * Built on top of [TrezorRepo], which owns the device list, connect orchestration * and the underlying watcher transport. */ -@Suppress("TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions") @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, @@ -89,7 +89,7 @@ class HwWalletRepo @Inject constructor( private val WATCHER_START_RETRY_DELAY = 30.seconds const val DEVICE_LABEL_MAX_LENGTH = 50 - /** Trezor v1 (2.4.0) tracks native segwit only; multi-type HW support is follow-up work. */ + /** Trezor v1 (2.4.0) tracks native SegWit accounts. */ private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey) } @@ -373,6 +373,59 @@ class HwWalletRepo @Inject constructor( } } + /** Estimates the exact funding fee from the public account key without opening the device. */ + suspend fun estimateFundingMiningFee( + walletId: String, + address: String, + sats: ULong, + satsPerVByte: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + composeFundingOffline( + walletId = walletId, + output = ComposeOutput.Payment(address = address, amountSats = sats), + satsPerVByte = satsPerVByte, + ).fee + } + } + + /** Exact amount available after the coin-selection fee, computed offline from the account xpub. */ + suspend fun maxSpendableFunding( + walletId: String, + address: String, + satsPerVByte: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val success = composeFundingOffline( + walletId = walletId, + output = ComposeOutput.SendMax(address = address), + satsPerVByte = satsPerVByte, + ) + success.totalSpent.safe() - success.fee.safe() + } + } + + private suspend fun composeFundingOffline( + walletId: String, + output: ComposeOutput, + satsPerVByte: ULong, + ): ComposeResult.Success { + val account = getFundingAccount(walletId).getOrThrow() + val composed = trezorRepo.composeTransactionOffline( + extendedKey = account.xpub, + outputs = listOf(output), + feeRates = listOf(satsPerVByte.toFloat()), + network = Env.network.toCoreNetwork(), + accountType = account.accountType, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ).getOrThrow() + return composed.filterIsInstance().firstOrNull() + ?: throw AppError( + composed.filterIsInstance().firstOrNull()?.error + ?: "Failed to compose hardware wallet payment" + ) + } + /** Signs a composed funding payment on the Trezor. */ suspend fun signFunding( walletId: String, @@ -388,7 +441,7 @@ class HwWalletRepo @Inject constructor( psbtBase64 = funding.psbt, network = Env.network.toTrezorCoinType(), ).getOrElse { - if (!it.isTrezorUserCancellation()) { + if (it.isTrezorSessionFailure()) { transportDeviceIdOrNull(walletId)?.let { deviceId -> trezorRepo.disconnectStaleSession(deviceId) } } throw it diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index a0e252bf77..69a4bb9775 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -507,25 +507,71 @@ class TrezorRepo @Inject constructor( awaitSetup() ensureConnected() val fingerprint = trezorService.getDeviceFingerprint() - val params = ComposeParams( - wallet = WalletParams( - extendedKey = extendedKey, - electrumUrl = currentElectrumUrl(), - fingerprint = fingerprint, - network = network, - accountType = accountType, - ), + composeTransaction( + extendedKey = extendedKey, outputs = outputs, feeRates = feeRates, + network = network, + accountType = accountType, coinSelection = coinSelection, + fingerprint = fingerprint, ) - trezorService.composeTransaction(params) }.onFailure { Logger.error("Trezor composeTransaction failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } } } + /** Composes from a public account key without opening a hardware-device session. */ + @Suppress("LongParameterList") + suspend fun composeTransactionOffline( + extendedKey: String, + outputs: List, + feeRates: List, + network: BitkitCoreNetwork, + accountType: AccountType?, + coinSelection: CoinSelection, + ): Result> = withContext(ioDispatcher) { + runSuspendCatching { + awaitSetup() + composeTransaction( + extendedKey = extendedKey, + outputs = outputs, + feeRates = feeRates, + network = network, + accountType = accountType, + coinSelection = coinSelection, + fingerprint = null, + ) + }.onFailure { + Logger.error("Trezor offline composeTransaction failed", it, context = TAG) + } + } + + @Suppress("LongParameterList") + private suspend fun composeTransaction( + extendedKey: String, + outputs: List, + feeRates: List, + network: BitkitCoreNetwork, + accountType: AccountType?, + coinSelection: CoinSelection, + fingerprint: String?, + ): List = trezorService.composeTransaction( + ComposeParams( + wallet = WalletParams( + extendedKey = extendedKey, + electrumUrl = currentElectrumUrl(), + fingerprint = fingerprint, + network = network, + accountType = accountType, + ), + outputs = outputs, + feeRates = feeRates, + coinSelection = coinSelection, + ) + ) + suspend fun signTxFromPsbt( psbtBase64: String, network: TrezorCoinType?, @@ -1334,8 +1380,9 @@ class TrezorRepo @Inject constructor( TrezorDebugLog.log("THPRetry", "Error not retryable, throwing") throw e } - TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") + TrezorDebugLog.log("THPRetry", "Error is retryable, resetting the session before reconnecting...") Logger.warn("Failed to connect to '$deviceId', retrying", e, context = TAG) + disconnectStaleSession(deviceId) logCredentialFileState(deviceId, "BEFORE 2nd attempt") val result = runSuspendCatching { connectDevice(deviceId, selection, requestUsbPermission) @@ -1355,8 +1402,11 @@ class TrezorRepo @Inject constructor( return@withContext Result.success(Unit) } val result = runSuspendCatching { - trezorService.disconnect() - disconnectTransportDevice(deviceId) + try { + trezorService.disconnect() + } finally { + disconnectTransportDevice(deviceId) + } } .onFailure { Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index a9c425fffd..e6fbe46259 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -240,6 +240,7 @@ class CoreService @Inject constructor( // region Activity private const val CHUNK_SIZE = 50 +private const val HW_PENDING_SEND_GRACE_PERIOD_SECONDS = 86_400L /** * Outcome of replacing a hardware wallet's on-chain snapshot. @@ -261,6 +262,9 @@ internal data class HwSnapshotMerge( /** * Builds the delete/upsert plan for a hardware wallet's on-chain snapshot. * + * Recent pending sends remain during the watcher's eventual-consistency window. Their persisted + * creation timestamp keeps that protection across process restarts. + * * @param transferChannelIdsByFundingTxId funding tx id to channel id for transfers Bitkit recorded * itself. Re-pairing a wallet that was removed leaves nothing to carry forward, because removal * deleted its activities, and [to.bitkit.repositories.TransferRepo.syncTransferStates] only re-marks @@ -271,10 +275,15 @@ internal data class HwSnapshotMerge( internal fun mergeHwSnapshot( existing: List, incoming: List, + currentTimestamp: ULong, transferChannelIdsByFundingTxId: Map, ): HwSnapshotMerge { val incomingIds = incoming.map { it.rawId() }.toSet() - val toDelete = existing.filter { !it.v1.isTransfer && it.v1.id !in incomingIds } + val toDelete = existing.filter { + !it.v1.isTransfer && + !it.v1.isRecentPendingSend(currentTimestamp) && + it.v1.id !in incomingIds + } val existingByTxId = existing.associateBy { it.v1.txId } val toUpsert = incoming.map { activity -> val onchain = activity as? Activity.Onchain ?: return@map activity @@ -286,12 +295,20 @@ internal fun mergeHwSnapshot( return HwSnapshotMerge(toDelete = toDelete, toUpsert = toUpsert) } +private fun OnchainActivity.isRecentPendingSend(currentTimestamp: ULong): Boolean { + val createdAt = createdAt ?: return false + if (txType != PaymentType.SENT || confirmed || !doesExist) return false + val age = (currentTimestamp.toLong() - createdAt.toLong()).coerceAtLeast(0) + return age <= HW_PENDING_SEND_GRACE_PERIOD_SECONDS +} + private fun OnchainActivity.mergedWith(stored: OnchainActivity?): OnchainActivity = when (stored) { null -> this else -> copy( isTransfer = isTransfer || stored.isTransfer, channelId = channelId ?: stored.channelId, transferTxId = transferTxId ?: stored.transferTxId, + contact = contact ?: stored.contact, ) } @@ -380,6 +397,7 @@ class ActivityService( val merge = mergeHwSnapshot( existing = existingActivities, incoming = activities, + currentTimestamp = nowTimestamp().epochSecond.toULong(), transferChannelIdsByFundingTxId = transferChannelIdsByFundingTxId, ) merge.toDelete.forEach { @@ -389,7 +407,6 @@ class ActivityService( if (merge.toUpsert.isNotEmpty()) upsertActivities(merge.toUpsert) if (transactionDetails.isNotEmpty()) upsertTransactionDetails(transactionDetails) - HwSnapshotResult( activities = getActivities( walletId = walletId, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ba977db73c..5daaa8a183 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -150,6 +150,7 @@ import to.bitkit.ui.screens.wallets.activity.DateRangeSelectorSheet import to.bitkit.ui.screens.wallets.activity.TagSelectorSheet import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet +import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen import to.bitkit.ui.screens.widgets.WidgetsIntroScreen import to.bitkit.ui.settings.BackupSettingsScreen @@ -451,6 +452,10 @@ fun ContentView( val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() + val hwSendViewModel = hiltViewModel() + val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() + val canDismissSheet = currentSheet !is Sheet.Send || + (!hwSendUiState.isSigning && !hwSendUiState.isBroadcastUnresolved) var homeWalletPageRequest by remember { mutableIntStateOf(0) } var homeWidgetsPageRequest by remember { mutableIntStateOf(0) } val navigateToHomeWallet = { @@ -473,6 +478,7 @@ fun ContentView( ) { SheetHost( shouldExpand = currentSheet != null, + canDismiss = canDismissSheet, onDismiss = { appViewModel.hideSheet() }, sheetHandlePlacement = when (currentSheet) { is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay @@ -490,6 +496,8 @@ fun ContentView( appViewModel = appViewModel, walletViewModel = walletViewModel, startDestination = sheet.route, + hardwareWalletId = sheet.hardwareWalletId, + hwSendViewModel = hwSendViewModel, ) } @@ -618,6 +626,10 @@ fun ContentView( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + val currentHardwareWalletId = navBackStackEntry + ?.takeIf { it.destination.hasRoute() } + ?.toRoute() + ?.walletId val showTabBar = currentRoute in listOf( Routes.Home::class.qualifiedName, Routes.AllActivity::class.qualifiedName, @@ -636,7 +648,9 @@ fun ContentView( if (showTabBar) { TabBar( isVisible = !hideTabBarForCalculator, - onSendClick = { appViewModel.showSheet(Sheet.Send()) }, + onSendClick = { + appViewModel.showSheet(Sheet.Send(hardwareWalletId = currentHardwareWalletId)) + }, onReceiveClick = { appViewModel.showSheet(Sheet.Receive()) }, onScanClick = { appViewModel.showScannerSheet() }, ) diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index d1b6c8d835..8e057dfebb 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -52,7 +53,10 @@ enum class SheetHandlePlacement { @Stable sealed interface Sheet { - data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet + data class Send( + val route: SendRoute = SendRoute.Recipient, + val hardwareWalletId: String? = null, + ) : Sheet data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet @@ -90,6 +94,7 @@ enum class TimedSheetType(val priority: Int) { @Composable fun SheetHost( shouldExpand: Boolean, + canDismiss: Boolean = true, onDismiss: () -> Unit = {}, sheetHandlePlacement: SheetHandlePlacement = SheetHandlePlacement.ScaffoldSlot, sheetContainerColor: Color = DefaultSheetContainerColor, @@ -97,8 +102,15 @@ fun SheetHost( content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() + val currentCanDismiss by rememberUpdatedState(canDismiss) + val currentShouldExpand by rememberUpdatedState(shouldExpand) val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { + it != SheetValue.Hidden || currentCanDismiss || !currentShouldExpand + }, + ) ) var wasSheetVisible by remember { mutableStateOf(false) } @@ -144,16 +156,20 @@ fun SheetHost( // Dismiss on back BackHandler(enabled = scaffoldState.bottomSheetState.isVisible) { - scope.launch { - scaffoldState.bottomSheetState.hide() - onDismiss() + if (canDismiss) { + scope.launch { + scaffoldState.bottomSheetState.hide() + onDismiss() + } } } Scrim(scaffoldState.bottomSheetState) { - scope.launch { - scaffoldState.bottomSheetState.hide() - onDismiss() + if (canDismiss) { + scope.launch { + scaffoldState.bottomSheetState.hide() + onDismiss() + } } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 7867d607dd..1529b51d9e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -247,7 +247,8 @@ fun ActivityDetailScreen( detailViewModel = detailViewModel, isCpfpChild = isCpfpChild, isHardware = uiState.isHardwareActivity, - showContactActions = isPaykitEnabled && !uiState.isHardwareActivity, + showContactActions = isPaykitEnabled && + (!uiState.isHardwareActivity || assignedContact != null), boostTxDoesExist = boostTxDoesExist, onCopy = { text -> app.toast( @@ -548,7 +549,7 @@ private fun ActivityDetailContent( } ContactTagsSection( - contact = assignedContact.takeIf { showContactActions }, + contact = assignedContact, tags = tags, onRemoveTag = onRemoveTag, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt new file mode 100644 index 0000000000..83ee6bf3e4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt @@ -0,0 +1,164 @@ +package to.bitkit.ui.screens.wallets.send + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +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 to.bitkit.R +import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HardwareTransferIllustration +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.transfer.hardware.HwPassphrasePromptSheet +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.SendUiState + +private const val SEND_SIGN_VISUAL_TOP_RATIO = 0.54f + +@Composable +fun HwSendSignScreen( + walletId: String, + sendUiState: SendUiState, + satsPerVByte: ULong, + viewModel: HwSendViewModel, + prepareContactPayment: suspend () -> Boolean, + onBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val request = HwSendRequest( + walletId = walletId, + address = sendUiState.address, + amountSats = sendUiState.amount, + satsPerVByte = satsPerVByte, + tags = sendUiState.selectedTags, + ) + + LaunchedEffect(walletId) { + viewModel.warmUp(walletId) + } + DisposableEffect(viewModel) { + onDispose(viewModel::cancel) + } + + HwSendSignContent( + amountSats = sendUiState.amount, + address = sendUiState.address, + isSigning = uiState.isSigning, + hasPendingBroadcast = uiState.hasPendingBroadcast, + onBack = { if (!uiState.isSigning && !uiState.isBroadcastUnresolved) onBack() }, + onOpenConnect = { viewModel.signAndBroadcast(request, prepareContactPayment) }, + ) + + if (uiState.isPassphraseRequired) { + HwPassphrasePromptSheet( + isVerifying = uiState.isVerifyingPassphrase, + onSubmit = { passphrase -> + viewModel.submitPassphrase(request, passphrase, prepareContactPayment) + }, + onDismiss = viewModel::dismissPassphrase, + ) + } +} + +@Composable +private fun HwSendSignContent( + amountSats: ULong, + address: String, + isSigning: Boolean, + hasPendingBroadcast: Boolean, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onOpenConnect: () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) { + HardwareTransferIllustration( + drawableRes = R.drawable.trezor, + topRatio = SEND_SIGN_VISUAL_TOP_RATIO, + ) + + Column(modifier = Modifier.fillMaxSize()) { + SheetTopBar( + titleText = stringResource(R.string.hardware__send_sign_title), + onBack = onBack, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + VerticalSpacer(16.dp) + BalanceHeaderView( + sats = amountSats.toLong(), + useSwipeToHide = false, + testTag = "HardwareSendAmount", + modifier = Modifier.fillMaxWidth() + ) + VerticalSpacer(40.dp) + Caption13Up( + text = stringResource(R.string.hardware__send_confirm_address), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + BodySSB( + text = address, + modifier = Modifier.testTag("HardwareSendAddress") + ) + VerticalSpacer(24.dp) + HorizontalDivider() + FillHeight() + PrimaryButton( + text = stringResource( + if (hasPendingBroadcast) R.string.common__retry else R.string.hardware__send_open_connect + ), + enabled = !isSigning, + isLoading = isSigning, + onClick = onOpenConnect, + modifier = Modifier.testTag("HardwareSendOpenTrezorConnect") + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + HwSendSignContent( + amountSats = 100_000u, + address = "bc1qexampleaddressforconfirmingonthedevice", + isSigning = false, + hasPendingBroadcast = false, + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt new file mode 100644 index 0000000000..67eb11bc38 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -0,0 +1,340 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import to.bitkit.R +import to.bitkit.ext.isBroadcastConnectivityFailure +import to.bitkit.ext.isTrezorDeviceBusy +import to.bitkit.ext.isTrezorFirmwareError +import to.bitkit.ext.isTrezorSessionFailure +import to.bitkit.ext.isTrezorUserCancellation +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx +import to.bitkit.models.HwFundingTransaction +import to.bitkit.models.Toast +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.HwPassphraseMismatchError +import to.bitkit.repositories.HwPassphraseRequiredError +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.services.CoreService +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class HwSendViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val hwWalletRepo: HwWalletRepo, + private val preActivityMetadataRepo: PreActivityMetadataRepo, + private val coreService: CoreService, + private val activityRepo: ActivityRepo, +) : ViewModel() { + private companion object { + const val TAG = "HwSendViewModel" + val RECONNECT_TIMEOUT = 30.seconds + val COMPOSE_TIMEOUT = 45.seconds + val SIGN_TIMEOUT = 120.seconds + val BROADCAST_TIMEOUT = 120.seconds + } + + private val _uiState = MutableStateFlow(HwSendUiState()) + val uiState = _uiState.asStateFlow() + + private val pendingResult = MutableStateFlow(null) + val results = pendingResult.filterNotNull() + + private var pendingBroadcast: PendingHwSendBroadcast? = null + private var signingWalletId: String? = null + private var signingJob: Job? = null + private var passphraseJob: Job? = null + + fun warmUp(walletId: String) { + hwWalletRepo.warmUpKnownDevice(walletId) + } + + fun signAndBroadcast( + request: HwSendRequest, + beforeBroadcast: suspend () -> Boolean = { true }, + ) { + if (_uiState.value.isSigning || signingJob?.isActive == true) return + if (pendingBroadcast?.matches(request) == false) return + signingWalletId = request.walletId + _uiState.update { it.copy(isSigning = true) } + signingJob = viewModelScope.launch { + try { + runSuspendCatching { + var pending = pendingBroadcast?.takeIf { it.matches(request) } + if (pending == null && hwWalletRepo.needsPassphrase(request.walletId)) { + _uiState.update { it.copy(isPassphraseRequired = true) } + return@runSuspendCatching + } + if (pending == null) { + val signedTx = prepareSignedTransaction( + walletId = request.walletId, + address = request.address, + amountSats = request.amountSats, + satsPerVByte = request.satsPerVByte, + ) + pending = PendingHwSendBroadcast(request, signedTx) + pendingBroadcast = pending + _uiState.update { it.copy(hasPendingBroadcast = true) } + } + var payment = checkNotNull(pending) { "Hardware payment was not prepared" } + if (payment.isPreparedForBroadcast.not()) { + if (!beforeBroadcast()) return@runSuspendCatching + payment = payment.copy(isPreparedForBroadcast = true) + pendingBroadcast = payment + } + _uiState.update { it.copy(isBroadcastUnresolved = true) } + val result = withTimeout(BROADCAST_TIMEOUT) { + hwWalletRepo.broadcastFunding(payment.signedTx).getOrThrow() + } + runSuspendCatching { persistResult(request, result) } + .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } + pendingResult.update { HwSendResult(request.walletId, result.txId, request.amountSats) } + }.onFailure { + handleFailure(it, request.walletId) + } + } finally { + _uiState.update { it.copy(isSigning = false) } + signingJob = null + } + } + } + + fun submitPassphrase( + request: HwSendRequest, + passphrase: String, + beforeBroadcast: suspend () -> Boolean = { true }, + ) { + if (passphrase.isEmpty()) return + val state = _uiState.value + if (!state.isPassphraseRequired) return + if (state.isVerifyingPassphrase) return + if (passphraseJob?.isActive == true) return + + _uiState.update { it.copy(isVerifyingPassphrase = true) } + passphraseJob = viewModelScope.launch { + try { + hwWalletRepo.reconnectWithPassphrase(request.walletId, passphrase) + .onSuccess { + if (!_uiState.value.isPassphraseRequired) return@onSuccess + _uiState.update { it.copy(isPassphraseRequired = false) } + signAndBroadcast(request, beforeBroadcast) + } + .onFailure { error -> + if (error is HwPassphraseMismatchError) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.hardware__passphrase_mismatch), + ) + } else { + handleFailure(error, request.walletId) + } + } + } finally { + _uiState.update { it.copy(isVerifyingPassphrase = false) } + passphraseJob = null + } + } + } + + fun dismissPassphrase() { + passphraseJob?.cancel() + passphraseJob = null + _uiState.update { it.copy(isPassphraseRequired = false, isVerifyingPassphrase = false) } + } + + fun cancel() { + passphraseJob?.cancel() + passphraseJob = null + _uiState.update { it.copy(isPassphraseRequired = false, isVerifyingPassphrase = false) } + if (_uiState.value.isBroadcastUnresolved) return + + signingJob?.cancel() + signingJob = null + pendingBroadcast = null + _uiState.update { it.copy(isSigning = false, hasPendingBroadcast = false) } + val walletId = signingWalletId ?: return + signingWalletId = null + viewModelScope.launch { hwWalletRepo.disconnectStaleSession(walletId) } + } + + fun completeBroadcast() { + pendingBroadcast = null + signingWalletId = null + pendingResult.update { null } + _uiState.update { + it.copy( + hasPendingBroadcast = false, + isBroadcastUnresolved = false, + ) + } + } + + private suspend fun prepareSignedTransaction( + walletId: String, + address: String, + amountSats: ULong, + satsPerVByte: ULong, + ): HwFundingSignedTx { + ensureConnected(walletId) + val funding = withTimeout(COMPOSE_TIMEOUT) { + hwWalletRepo.composeFundingTransaction( + walletId = walletId, + address = address, + sats = amountSats, + satsPerVByte = satsPerVByte, + ).getOrThrow() + } + return sign(walletId, funding) + } + + private suspend fun sign(walletId: String, funding: HwFundingTransaction): HwFundingSignedTx { + val firstAttempt = runSuspendCatching { signWithTimeoutCleanup(walletId, funding) } + val error = firstAttempt.exceptionOrNull() ?: return firstAttempt.getOrThrow() + if (!error.isTrezorSessionFailure()) throw error + + ensureConnected(walletId) + return signWithTimeoutCleanup(walletId, funding) + } + + private suspend fun ensureConnected(walletId: String) { + withTimeout(RECONNECT_TIMEOUT) { + hwWalletRepo.ensureConnected(walletId).getOrThrow() + } + } + + private suspend fun signWithTimeoutCleanup( + walletId: String, + funding: HwFundingTransaction, + ): HwFundingSignedTx = try { + signOnce(walletId, funding) + } catch (error: TimeoutCancellationException) { + hwWalletRepo.disconnectStaleSession(walletId) + throw error + } + + private suspend fun signOnce(walletId: String, funding: HwFundingTransaction): HwFundingSignedTx = + withTimeout(SIGN_TIMEOUT) { + hwWalletRepo.signFunding(walletId, funding).getOrThrow() + } + + private suspend fun persistResult(request: HwSendRequest, result: HwFundingBroadcastResult) { + if (request.tags.isNotEmpty()) { + preActivityMetadataRepo.savePreActivityMetadata( + id = result.txId, + txId = result.txId, + address = request.address, + isReceive = false, + tags = request.tags, + feeRate = result.feeRate, + walletId = request.walletId, + ) + } + coreService.activity.createSentOnchainActivityFromSendResult( + txid = result.txId, + address = request.address, + amount = request.amountSats, + fee = result.miningFeeSats, + feeRate = result.feeRate, + isTransfer = false, + channelId = null, + walletId = request.walletId, + ) + if (request.tags.isNotEmpty()) { + activityRepo.addTagsToActivity(result.txId, request.tags, request.walletId) + } + activityRepo.notifyPaymentActivityChanged() + } + + private suspend fun handleFailure(error: Throwable, walletId: String) { + when { + error.isTrezorUserCancellation() -> { + Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) + } + generateSequence(error) { it.cause }.any { it is HwPassphraseRequiredError } -> { + _uiState.update { it.copy(isPassphraseRequired = true) } + } + error.isTrezorDeviceBusy() -> ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.hardware__device_busy), + ) + error.isTrezorFirmwareError() -> 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), + ) + pendingBroadcast != null && + (error.isBroadcastConnectivityFailure() || error is TimeoutCancellationException) -> ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__connection_issue), + description = context.getString(R.string.other__connection_issues_explain), + ) + error is TimeoutCancellationException -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__payment_timeout), + ) + else -> { + if (pendingBroadcast != null) { + pendingBroadcast = null + _uiState.update { + it.copy( + hasPendingBroadcast = false, + isBroadcastUnresolved = false, + ) + } + } + ToastEventBus.send(error) + } + } + } +} + +@Immutable +data class HwSendUiState( + val isSigning: Boolean = false, + val hasPendingBroadcast: Boolean = false, + val isBroadcastUnresolved: Boolean = false, + val isPassphraseRequired: Boolean = false, + val isVerifyingPassphrase: Boolean = false, +) + +data class HwSendResult( + val walletId: String, + val txId: String, + val amountSats: ULong, +) + +data class HwSendRequest( + val walletId: String, + val address: String, + val amountSats: ULong, + val satsPerVByte: ULong, + val tags: List, +) + +private data class PendingHwSendBroadcast( + val request: HwSendRequest, + val signedTx: HwFundingSignedTx, + val isPreparedForBroadcast: Boolean = false, +) { + fun matches(request: HwSendRequest): Boolean = this.request == request +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index fed94b10a2..a9025ed6ec 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -213,7 +213,7 @@ fun SendAmountContent( } } -@Suppress("ViewModelForwarding") +@Suppress("ViewModelForwarding", "CyclomaticComplexMethod") @Composable private fun SendAmountNodeRunning( amountInputViewModel: AmountInputViewModel, @@ -230,6 +230,7 @@ private fun SendAmountNodeRunning( val availableAmount = when { isLnurlWithdraw -> uiState.lnurl.data.maxWithdrawableSat().toLong() + uiState.hardwareWalletId != null -> uiState.hardwareAvailableSats.toLong() uiState.payMethod == SendMethod.ONCHAIN -> balances.maxSendOnchainSats.toLong() else -> { val maxLightning = balances.maxSendLightningSats @@ -263,6 +264,7 @@ private fun SendAmountNodeRunning( val textAvailable = when { uiState.lnurl is LnurlParams.LnurlWithdraw -> R.string.wallet__lnurl_w_max + uiState.hardwareWalletId != null -> R.string.wallet__send_available uiState.isUnified -> R.string.wallet__send_available uiState.payMethod == SendMethod.ONCHAIN -> R.string.wallet__send_available_savings uiState.payMethod == SendMethod.LIGHTNING -> R.string.wallet__send_available_spending @@ -342,23 +344,27 @@ private fun PaymentMethodButton( uiState: SendUiState, onClick: () -> Unit, ) { + val isHardware = uiState.hardwareWalletId != null val testId = when { - uiState.isUnified -> "switch" + uiState.canSwitchFundingSource -> "switch" + isHardware -> "trezor" uiState.payMethod == SendMethod.ONCHAIN -> "savings" else -> "spending" } NumberPadActionButton( - text = when (uiState.payMethod) { - SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) - SendMethod.LIGHTNING -> stringResource(R.string.wallet__spending__title) + text = when { + isHardware -> uiState.hardwareWalletName ?: stringResource(R.string.hardware__device_model_trezor) + uiState.payMethod == SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) + else -> stringResource(R.string.wallet__spending__title) }, - color = when (uiState.payMethod) { - SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple + color = when { + isHardware -> Colors.Blue + uiState.payMethod == SendMethod.ONCHAIN -> Colors.Brand + else -> Colors.Purple }, - icon = if (uiState.isUnified) R.drawable.ic_transfer else null, + icon = if (uiState.canSwitchFundingSource) R.drawable.ic_transfer else null, onClick = onClick, - enabled = uiState.isUnified, + enabled = uiState.canSwitchFundingSource && !uiState.isLoading, modifier = Modifier .height(28.dp) .testTag("AssetButton-$testId") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 5d0929bdce..f9ac40da94 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -103,6 +103,7 @@ import kotlin.time.Duration.Companion.seconds private val EXPIRY_REFRESH_INTERVAL = 60.seconds private const val SWIPE_ROTATION_DEGREES = 14f private const val IMAGE_FILL_PERCENTAGE = 0.8f +const val HARDWARE_SIGN_CANCELLED_RESULT_KEY = "HARDWARE_SIGN_CANCELLED_RESULT_KEY" @Suppress("MagicNumber") @Composable @@ -138,6 +139,15 @@ fun SendConfirmScreen( } } + LaunchedEffect(savedStateHandle) { + savedStateHandle.getStateFlow(HARDWARE_SIGN_CANCELLED_RESULT_KEY, false) + .collect { + if (!it) return@collect + isLoading = false + savedStateHandle.remove(HARDWARE_SIGN_CANCELLED_RESULT_KEY) + } + } + // Confirm with pin or bio if required LaunchedEffect(uiState.shouldConfirmPay) { if (!uiState.shouldConfirmPay) return@LaunchedEffect @@ -497,10 +507,16 @@ private fun OnChainDetails( modifier = Modifier.weight(1f) ) { NumberPadActionButton( - text = stringResource(R.string.wallet__savings__title), - color = Colors.Brand, - enabled = uiState.canSwitchWallet, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchWallet }, + text = if (uiState.hardwareWalletId != null) { + uiState.hardwareWalletName ?: stringResource(R.string.hardware__device_model_trezor) + } else { + stringResource(R.string.wallet__savings__title) + }, + color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, + enabled = uiState.canSwitchFundingSource, + icon = R.drawable.ic_transfer.takeIf { + uiState.canSwitchFundingSource + }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -623,8 +639,8 @@ private fun LightningDetails( NumberPadActionButton( text = stringResource(R.string.wallet__spending__title), color = Colors.Purple, - enabled = uiState.canSwitchWallet, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchWallet }, + enabled = uiState.canSwitchFundingSource, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -710,7 +726,7 @@ private fun LightningDetails( tint = Colors.Purple, modifier = Modifier.size(16.dp) ) - val timestampSeconds = uiState.decodedInvoice?.timestampSeconds ?: 0uL + val timestampSeconds = uiState.decodedInvoice.timestampSeconds val invoiceExpiryText by produceState("", timestampSeconds, expirySeconds) { val expiryMoment = timestampSeconds + expirySeconds while (true) { diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index a0f3296364..68039b1b34 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -31,6 +31,7 @@ import androidx.navigation.toRoute import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.NewTransactionSheetDetails @@ -43,6 +44,9 @@ import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen import to.bitkit.ui.screens.wallets.send.AddTagScreen +import to.bitkit.ui.screens.wallets.send.HARDWARE_SIGN_CANCELLED_RESULT_KEY +import to.bitkit.ui.screens.wallets.send.HwSendSignScreen +import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen import to.bitkit.ui.screens.wallets.send.SendAmountScreen @@ -75,31 +79,35 @@ import to.bitkit.viewmodels.SendMethod import to.bitkit.viewmodels.SendUiState import to.bitkit.viewmodels.WalletViewModel +private const val HARDWARE_SEND_FALLBACK_SATS_PER_VBYTE = 3uL + @Suppress("CyclomaticComplexMethod") @Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, + hwSendViewModel: HwSendViewModel, startDestination: SendRoute = SendRoute.Recipient, + hardwareWalletId: String? = null, ) { val context = LocalContext.current val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() val isOffline by remember { derivedStateOf { connectivityState != ConnectivityState.CONNECTED } } val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() var routingCacheResetAttempted by rememberSaveable(startDestination) { mutableStateOf(false) } - val shouldShowSyncOverlay by remember { - derivedStateOf { - if (!lightningState.nodeLifecycleState.isRunning()) return@derivedStateOf true - val hasAnyChannels = lightningState.channels.isNotEmpty() - hasAnyChannels && lightningState.channels.none { it.isUsable } - } + val shouldShowSyncOverlay = run { + if (sendUiState.hardwareWalletId != null) return@run false + if (!lightningState.nodeLifecycleState.isRunning()) return@run true + val hasAnyChannels = lightningState.channels.isNotEmpty() + hasAnyChannels && lightningState.channels.none { it.isUsable } } LaunchedEffect(startDestination) { // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { - appViewModel.resetSendState() + appViewModel.resetSendState(hardwareWalletId = hardwareWalletId) appViewModel.resetQuickPay() routingCacheResetAttempted = false } @@ -116,6 +124,26 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() + LaunchedEffect(hwSendViewModel, navController) { + hwSendViewModel.results.collect { result -> + appViewModel.onSendSuccess( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = result.txId, + activityWalletId = result.walletId, + sats = result.amountSats.toLong(), + ), + walletId = result.walletId, + navigate = false, + ) + appViewModel.clearClipboardForAutoRead() + navController.navigateTo(SendRoute.Success) { + popUpTo(navController.graph.id) { inclusive = true } + } + hwSendViewModel.completeBroadcast() + } + } LaunchedEffect(appViewModel, navController) { appViewModel.sendEffect.collect { when (it) { @@ -124,6 +152,7 @@ fun SendSheet( is SendEffect.NavigateToScan -> navController.navigateTo(SendRoute.QrScanner) is SendEffect.NavigateToCoinSelection -> navController.navigateTo(SendRoute.CoinSelection) is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) + is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { appViewModel.clearClipboardForAutoRead() @@ -187,7 +216,11 @@ fun SendSheet( val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() SendAmountScreen( uiState = uiState, - nodeLifecycleState = lightningState.nodeLifecycleState, + nodeLifecycleState = if (uiState.hardwareWalletId != null) { + to.bitkit.models.NodeLifecycleState.Running + } else { + lightningState.nodeLifecycleState + }, canGoBack = startDestination != SendRoute.Amount, onBack = { if (!navController.popBackStack()) { @@ -249,7 +282,8 @@ fun SendSheet( SendConfirmScreen( savedStateHandle = it.savedStateHandle, uiState = uiState, - isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + isNodeRunning = uiState.hardwareWalletId != null || + lightningState.nodeLifecycleState.isRunning(), canGoBack = startDestination != SendRoute.Confirm, onBack = { val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) @@ -263,6 +297,32 @@ fun SendSheet( onNavigateToPin = { navController.navigateTo(SendRoute.PinCheck) }, ) } + composableWithDefaultTransitions { + val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() + val walletId = uiState.hardwareWalletId ?: run { + navController.popBackStack() + return@composableWithDefaultTransitions + } + val satsPerVByte = uiState.feeRates + ?.getSatsPerVByteFor(uiState.speed) + ?.toULong() + ?.takeIf { rate -> rate > 0uL } + ?: HARDWARE_SEND_FALLBACK_SATS_PER_VBYTE + HwSendSignScreen( + walletId = walletId, + sendUiState = uiState, + satsPerVByte = satsPerVByte, + viewModel = hwSendViewModel, + prepareContactPayment = appViewModel::prepareHardwareContactPayment, + onBack = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(HARDWARE_SIGN_CANCELLED_RESULT_KEY, true) + appViewModel.onHardwareSignCancelled() + navController.popBackStack() + }, + ) + } composableWithDefaultTransitions { val sendDetail by appViewModel.successSendUiState.collectAsStateWithLifecycle() NewTransactionSheetView( @@ -541,6 +601,9 @@ sealed interface SendRoute { @Serializable data object Confirm : InternalOnly + @Serializable + data object HardwareSign : InternalOnly + @Serializable data object Success : InternalOnly diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index fe31b57c88..fb91ee225b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -220,6 +220,7 @@ class ActivityDetailViewModel @Inject constructor( activityRepo.clearContact( forPaymentId = id, syncLdkPayments = false, + walletId = currentActivity.walletId(), ).onSuccess { reloadActivity(id, currentActivity.walletId()) }.onFailure { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fe39891d7a..c60be7863f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -126,6 +126,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.models.USD +import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -281,7 +282,15 @@ class AppViewModel @Inject constructor( private fun mainScreenEffect(effect: MainScreenEffect) = viewModelScope.launch { _mainScreenEffect.emit(effect) } private val sendEvents = MutableSharedFlow() - fun setSendEvent(event: SendEvent) = viewModelScope.launch { sendEvents.emit(event) } + private var amountContinuePending = false + + fun setSendEvent(event: SendEvent) { + if (event == SendEvent.AmountContinue) { + if (amountContinuePending) return + amountContinuePending = true + } + viewModelScope.launch { sendEvents.emit(event) } + } private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() @@ -310,6 +319,7 @@ class AppViewModel @Inject constructor( private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() private val presentedPaymentRequestIds = mutableSetOf() + private var preparedContactPaymentContext: ContactPaymentContext? = null private var isPresentingPaymentRequest = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null @@ -1442,7 +1452,11 @@ class AppViewModel @Inject constructor( is SendEvent.AmountChange -> onAmountChange(it.amount) SendEvent.AmountReset -> resetAmountInput() - SendEvent.AmountContinue -> onAmountContinue() + SendEvent.AmountContinue -> try { + onAmountContinue() + } finally { + amountContinuePending = false + } SendEvent.PaymentMethodSwitch -> onPaymentMethodSwitch() is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) @@ -1480,6 +1494,9 @@ class AppViewModel @Inject constructor( private val isMainScanner get() = currentSheet.value !is Sheet.Send + private val activeHardwareWalletId: String? + get() = (currentSheet.value as? Sheet.Send)?.hardwareWalletId + private fun onEnterManuallyClick() { clearActiveContactPaymentContext() resetAddressInput() @@ -1552,9 +1569,17 @@ class AppViewModel @Inject constructor( } when (val decoded = scanResult.getOrNull()) { - is Scanner.Lightning -> validateLightningInvoice(decoded.invoice) is Scanner.OnChain -> validateOnChainAddress(decoded.invoice) - else -> _sendUiState.update { it.copy(isAddressInputValid = true) } + is Scanner.Lightning -> if (activeHardwareWalletId == null) { + validateLightningInvoice(decoded.invoice) + } else { + showHardwareOnchainOnlyValidationError() + } + else -> if (activeHardwareWalletId == null) { + _sendUiState.update { it.copy(isAddressInputValid = true) } + } else { + showHardwareOnchainOnlyValidationError() + } } } @@ -1606,20 +1631,26 @@ class AppViewModel @Inject constructor( return } - extractViableLightningInvoice(invoice.params)?.let { lnInvoice -> - _sendUiState.update { - it.copy( - isAddressInputValid = true, - isUnified = true, - decodedInvoice = lnInvoice, - payMethod = SendMethod.LIGHTNING, - ) + val hardwareWalletId = activeHardwareWalletId + if (hardwareWalletId == null) { + extractViableLightningInvoice(invoice.params)?.let { lnInvoice -> + _sendUiState.update { + it.copy( + isAddressInputValid = true, + isUnified = true, + decodedInvoice = lnInvoice, + payMethod = SendMethod.LIGHTNING, + ) + } + updateCanSwitchWallet() + return } - updateCanSwitchWallet() - return } - val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats + val selectedMaxSendOnchain = hardwareWalletId?.let { + hardwareMaxSpendable(it, invoice.address, _sendUiState.value.speed) + } ?: walletRepo.balanceState.value.maxSendOnchainSats + val maxSendOnchain = maximumAvailableOnchainSats(selectedMaxSendOnchain, hardwareWalletId) if (maxSendOnchain == 0uL) { showAddressValidationError( @@ -1644,6 +1675,14 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } + private fun showHardwareOnchainOnlyValidationError() { + showAddressValidationError( + titleRes = R.string.hardware__send_onchain_only_title, + descriptionRes = R.string.hardware__send_onchain_only_text, + testTag = "HardwareOnchainOnlyToast", + ) + } + private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> runSuspendCatching { coreService.decode(bolt11) }.getOrNull() @@ -1892,16 +1931,32 @@ class AppViewModel @Inject constructor( else -> false } + val wasHardwareMax = state.hardwareWalletId != null && + state.amount > 0uL && + state.amount == state.hardwareAvailableSats + val hardwareAvailableSats = state.hardwareWalletId?.let { walletId -> + hardwareMaxSpendable(walletId, state.address, speed) + } ?: state.hardwareAvailableSats + _sendUiState.update { + it.copy( + payMethod = SendMethod.ONCHAIN, + speed = speed, + amount = if (wasHardwareMax) hardwareAvailableSats else it.amount, + hardwareAvailableSats = hardwareAvailableSats, + selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos, + ) + } val fee = when (speed is TransactionSpeed.Custom) { true -> getFeeEstimate(speed) - else -> state.fees.getOrDefault(FeeRate.fromSpeed(speed), 0) + else -> if (state.hardwareWalletId != null) { + getFeeEstimate(speed) + } else { + state.fees.getOrDefault(FeeRate.fromSpeed(speed), 0) + } } _sendUiState.update { it.copy( - payMethod = SendMethod.ONCHAIN, - speed = speed, fee = SendFee.OnChain(fee), - selectedUtxos = if (shouldResetUtxos) null else it.selectedUtxos, ) } updateCanSwitchWallet() @@ -1912,44 +1967,112 @@ class AppViewModel @Inject constructor( private fun updateCanSwitchWallet() { val state = _sendUiState.value - if (!state.isUnified) { - _sendUiState.update { it.copy(canSwitchWallet = false) } - return + val canSwitchWallet = if (state.hardwareWalletId != null || !state.isUnified) { + false + } else { + val amount = state.amount + val balance = walletRepo.balanceState.value + amount > Defaults.dustLimit.toULong() && + amount <= balance.maxSendOnchainSats && + amount <= balance.maxSendLightningSats + } + _sendUiState.update { + it.copy( + canSwitchWallet = canSwitchWallet, + canSwitchFundingSource = availableFundingSources(state).size > 1, + ) } - val amount = state.amount - val balance = walletRepo.balanceState.value - val canSwitch = amount > Defaults.dustLimit.toULong() && - amount <= balance.maxSendOnchainSats && - amount <= balance.maxSendLightningSats - _sendUiState.update { it.copy(canSwitchWallet = canSwitch) } } private suspend fun onPaymentMethodSwitch() { val current = _sendUiState.value - if (!current.isUnified) return + val sources = availableFundingSources(current) + if (sources.size < 2) return + val selected = current.selectedFundingSource() + val selectedIndex = sources.indexOf(selected).takeIf { it >= 0 } ?: 0 + when (val nextSource = sources[(selectedIndex + 1) % sources.size]) { + SendFundingSource.Spending -> { + _sendUiState.update { + it.copy( + payMethod = SendMethod.LIGHTNING, + hardwareWalletId = null, + hardwareWalletName = null, + hardwareAvailableSats = 0uL, + fee = SendFee.Lightning(0), + selectedUtxos = null, + confirmedWarnings = persistentListOf(), + ) + } + estimateLightningRoutingFeesIfNeeded() + } - val nextMethod = when (current.payMethod) { - SendMethod.ONCHAIN -> SendMethod.LIGHTNING - SendMethod.LIGHTNING -> SendMethod.ONCHAIN + SendFundingSource.Savings -> { + _sendUiState.update { + it.copy( + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = null, + hardwareWalletName = null, + hardwareAvailableSats = 0uL, + selectedUtxos = null, + confirmedWarnings = persistentListOf(), + ) + } + refreshOnchainSendIfNeeded() + } + + is SendFundingSource.Hardware -> selectHardwareFundingSource(nextSource.walletId, current) + } + _sendUiState.update { + it.copy( + isAmountInputValid = validateAmount(it.amount), + ) } + updateCanSwitchWallet() + } + + private suspend fun selectHardwareFundingSource(walletId: String, current: SendUiState) { + val initialAvailable = hardwareEstimatedAvailable(walletId, current.speed) + val walletName = hwWalletRepo.wallets.value.find { it.id == walletId }?.name _sendUiState.update { it.copy( - payMethod = nextMethod, - isAmountInputValid = validateAmount(it.amount, nextMethod), + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = walletId, + hardwareWalletName = walletName, + hardwareAvailableSats = initialAvailable, + isAmountInputValid = it.amount > Defaults.dustLimit.toULong() && it.amount <= initialAvailable, + selectedUtxos = null, confirmedWarnings = persistentListOf(), ) } - when (nextMethod) { - SendMethod.ONCHAIN -> { - val defaultSpeed = settingsStore.data.first().defaultTransactionSpeed - _sendUiState.update { it.copy(speed = defaultSpeed) } - refreshFeeEstimates() - } - SendMethod.LIGHTNING -> { - _sendUiState.update { it.copy(fee = SendFee.Lightning(0)) } - estimateLightningRoutingFeesIfNeeded() + val available = hardwareMaxSpendable(walletId, current.address, current.speed) + _sendUiState.update { + if (it.hardwareWalletId != walletId) return@update it + it.copy(hardwareAvailableSats = available) + } + refreshOnchainSendIfNeeded() + } + + private suspend fun selectHardwareFundingSourceForAmount(amount: ULong): Boolean { + val state = _sendUiState.value + for (wallet in hwWalletRepo.wallets.value) { + val available = hardwareMaxSpendable(wallet.id, state.address, state.speed) + if (amount > available) continue + _sendUiState.update { + it.copy( + payMethod = SendMethod.ONCHAIN, + hardwareWalletId = wallet.id, + hardwareWalletName = wallet.name, + hardwareAvailableSats = available, + isAmountInputValid = true, + selectedUtxos = null, + confirmedWarnings = persistentListOf(), + ) } + updateCanSwitchWallet() + refreshOnchainSendIfNeeded() + return true } + return false } fun switchToLightning() { @@ -1957,6 +2080,9 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( payMethod = SendMethod.LIGHTNING, + hardwareWalletId = null, + hardwareWalletName = null, + hardwareAvailableSats = 0uL, fee = SendFee.Lightning(0), isAmountInputValid = validateAmount(it.amount, SendMethod.LIGHTNING), confirmedWarnings = persistentListOf(), @@ -1967,13 +2093,18 @@ class AppViewModel @Inject constructor( } private suspend fun onAmountContinue() { + if (_sendUiState.value.isLoading) return _sendUiState.update { it.copy( selectedUtxos = null, ) } - if (_sendUiState.value.payMethod != SendMethod.LIGHTNING && !settingsStore.data.first().coinSelectAuto) { + if ( + _sendUiState.value.hardwareWalletId == null && + _sendUiState.value.payMethod != SendMethod.LIGHTNING && + !settingsStore.data.first().coinSelectAuto + ) { setSendEffect(SendEffect.NavigateToCoinSelection) return } @@ -1998,14 +2129,39 @@ class AppViewModel @Inject constructor( } _sendUiState.update { it.copy(isLoading = true) } - refreshOnchainSendIfNeeded() - estimateLightningRoutingFeesIfNeeded() - _sendUiState.update { it.copy(isLoading = false) } + try { + if (!prepareHardwareSendFee()) return + refreshOnchainSendIfNeeded() + estimateLightningRoutingFeesIfNeeded() + } finally { + _sendUiState.update { it.copy(isLoading = false) } + } updateCanSwitchWallet() setSendEffect(SendEffect.NavigateToConfirm) } + private suspend fun prepareHardwareSendFee(): Boolean { + val state = _sendUiState.value + val walletId = state.hardwareWalletId ?: return true + val satsPerVByte = state.feeRates + ?.getSatsPerVByteFor(state.speed) + ?.toULong() + ?.takeIf { it > 0uL } + ?: HW_SEND_FALLBACK_SATS_PER_VBYTE + val miningFeeSats = hwWalletRepo.estimateFundingMiningFee( + walletId = walletId, + address = state.address, + sats = state.amount, + satsPerVByte = satsPerVByte, + ).getOrElse { error -> + toast(error) + return false + } + _sendUiState.update { it.copy(fee = SendFee.OnChain(miningFeeSats.toLong())) } + return true + } + private suspend fun onCoinSelectionContinue(utxos: List) { _sendUiState.update { it.copy(selectedUtxos = utxos.toImmutableList()) @@ -2034,7 +2190,9 @@ class AppViewModel @Inject constructor( } SendMethod.ONCHAIN -> { - val maxSendable = walletRepo.balanceState.value.maxSendOnchainSats + val maxSendable = _sendUiState.value.hardwareAvailableSats + .takeIf { _sendUiState.value.hardwareWalletId != null } + ?: walletRepo.balanceState.value.maxSendOnchainSats amount > Defaults.dustLimit.toULong() && amount <= maxSendable } } @@ -2196,27 +2354,39 @@ class AppViewModel @Inject constructor( scan: Scanner?, input: String, fromMainScanner: Boolean, - ) = when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) - is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) - is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } - is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } - is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } - is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } - is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } - else -> { - hideSheet() - Logger.warn( - if (scan == null) "Failed to decode scan data" else "Received unhandled scan data '$scan'", - context = TAG, - ) + ) { + if (activeHardwareWalletId != null && scan != null && scan !is Scanner.OnChain) { toast( type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), + title = context.getString(R.string.hardware__send_onchain_only_title), + description = context.getString(R.string.hardware__send_onchain_only_text), ) clearActiveContactPaymentContext() + return + } + + when (scan) { + is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) + is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) + is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } + is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } + is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } + is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } + is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } + else -> { + hideSheet() + Logger.warn( + if (scan == null) "Failed to decode scan data" else "Received unhandled scan data '$scan'", + context = TAG, + ) + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + clearActiveContactPaymentContext() + } } } @@ -2260,12 +2430,16 @@ class AppViewModel @Inject constructor( fun clearActiveContactPaymentContext() { synchronized(contactPaymentContextLock) { activeContactPaymentContext = null + preparedContactPaymentContext = null } + isSubmittingPaymentRequest = false } private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { val replacedRequestId = synchronized(contactPaymentContextLock) { - val currentRequestId = activeContactPaymentContext?.incomingPaymentRequest?.id + val currentContext = activeContactPaymentContext + val currentRequestId = currentContext?.incomingPaymentRequest?.id + if (currentContext != context) preparedContactPaymentContext = null activeContactPaymentContext = context currentRequestId?.takeIf { it != context?.incomingPaymentRequest?.id } } @@ -2327,11 +2501,21 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() return } - val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats + val hardwareWalletId = activeHardwareWalletId + val selectedMaxSendOnchain = if (hardwareWalletId != null) { + hardwareMaxSpendable(hardwareWalletId, invoice.address, _sendUiState.value.speed) + } else { + walletRepo.balanceState.value.maxSendOnchainSats + } + val maxSendOnchain = maximumAvailableOnchainSats(selectedMaxSendOnchain, hardwareWalletId) val incomingPaymentRequest = activeIncomingPaymentRequest() - val lnInvoice = extractViableLightningInvoice(invoice.params)?.takeIf { - incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(it.amountSatoshis) != false + val lnInvoice = if (hardwareWalletId == null) { + extractViableLightningInvoice(invoice.params)?.takeIf { + incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(it.amountSatoshis) != false + } + } else { + null } val amount = incomingPaymentRequest?.amountSats ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } @@ -2342,9 +2526,16 @@ class AppViewModel @Inject constructor( addressInput = scanResult, isAddressInputValid = true, amount = amount, - isUnified = lnInvoice != null && amount <= maxSendOnchain && maxSendOnchain > 0u, + isUnified = hardwareWalletId == null && lnInvoice != null && + amount <= maxSendOnchain && maxSendOnchain > 0u, decodedInvoice = lnInvoice, - payMethod = lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN, + payMethod = if (hardwareWalletId != null) { + SendMethod.ONCHAIN + } else { + lnInvoice?.let { SendMethod.LIGHTNING } ?: SendMethod.ONCHAIN + }, + hardwareWalletId = hardwareWalletId, + hardwareAvailableSats = selectedMaxSendOnchain.takeIf { hardwareWalletId != null } ?: 0uL, ) } updateCanSwitchWallet() @@ -2356,12 +2547,19 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(payMethod = SendMethod.ONCHAIN) } } } + if ( + !validateAmount(amount) && + _sendUiState.value.payMethod == SendMethod.ONCHAIN && + _sendUiState.value.hardwareWalletId == null + ) { + selectHardwareFundingSourceForAmount(amount) + } if (!validateAmount(amount)) { val isLightning = _sendUiState.value.payMethod == SendMethod.LIGHTNING val maxSendable = if (isLightning) { walletRepo.balanceState.value.maxSendLightningSats } else { - walletRepo.balanceState.value.maxSendOnchainSats + maxSendOnchain } val shortfall = amount.safe() - maxSendable.safe() toast( @@ -2452,6 +2650,65 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } + private suspend fun hardwareMaxSpendable( + walletId: String, + address: String, + speed: TransactionSpeed, + ): ULong { + val satsPerVByte = hardwareSatsPerVByte(speed) + return hwWalletRepo.maxSpendableFunding( + walletId = walletId, + address = address, + satsPerVByte = satsPerVByte, + ).getOrElse { + hardwareEstimatedAvailable(walletId, speed) + } + } + + private fun hardwareEstimatedAvailable( + walletId: String, + speed: TransactionSpeed, + feeRates: FeeRates? = _sendUiState.value.feeRates, + ): ULong { + val balance = hwWalletRepo.wallets.value.find { it.id == walletId }?.fundingBalanceSats ?: 0uL + val reserve = HW_SEND_FALLBACK_TX_VBYTES.safe() * hardwareSatsPerVByte(speed, feeRates).safe() + return balance.safe() - reserve.safe() + } + + private fun hardwareSatsPerVByte( + speed: TransactionSpeed, + feeRates: FeeRates? = _sendUiState.value.feeRates, + ): ULong = + feeRates + ?.getSatsPerVByteFor(speed) + ?.toULong() + ?.takeIf { it > 0uL } + ?: HW_SEND_FALLBACK_SATS_PER_VBYTE + + private fun maximumHardwareFundingBalanceSats(): ULong = + hwWalletRepo.wallets.value.maxOfOrNull { it.fundingBalanceSats } ?: 0uL + + private fun maximumAvailableOnchainSats(selectedMax: ULong, hardwareWalletId: String?): ULong = + if (hardwareWalletId == null) maxOf(selectedMax, maximumHardwareFundingBalanceSats()) else selectedMax + + private fun availableFundingSources(state: SendUiState): List = buildList { + if (state.isUnified || state.decodedInvoice != null) add(SendFundingSource.Spending) + if (state.address.isNotEmpty()) { + add(SendFundingSource.Savings) + hwWalletRepo.wallets.value.forEach { wallet -> + if (wallet.fundingBalanceSats > 0uL || wallet.id == state.hardwareWalletId) { + add(SendFundingSource.Hardware(wallet.id)) + } + } + } + } + + private fun SendUiState.selectedFundingSource(): SendFundingSource = when { + hardwareWalletId != null -> SendFundingSource.Hardware(hardwareWalletId) + payMethod == SendMethod.LIGHTNING -> SendFundingSource.Spending + else -> SendFundingSource.Savings + } + private suspend fun onScanLightning( invoice: LightningInvoice, scanResult: String, @@ -2793,7 +3050,11 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val balanceToCheck = when (_sendUiState.value.payMethod) { - SendMethod.ONCHAIN -> walletRepo.balanceState.value.maxSendOnchainSats + SendMethod.ONCHAIN -> { + _sendUiState.value.hardwareAvailableSats + .takeIf { _sendUiState.value.hardwareWalletId != null } + ?: walletRepo.balanceState.value.maxSendOnchainSats + } SendMethod.LIGHTNING -> walletRepo.balanceState.value.maxSendLightningSats } if ( @@ -2821,12 +3082,16 @@ class AppViewModel @Inject constructor( if (_sendUiState.value.payMethod != SendMethod.ONCHAIN) return - val totalFee = lightningRepo.calculateTotalFee( - amountSats = amountSats, - address = _sendUiState.value.address, - speed = _sendUiState.value.speed, - utxosToSpend = _sendUiState.value.selectedUtxos, - ).getOrNull() ?: return + val totalFee = if (_sendUiState.value.hardwareWalletId != null) { + (_sendUiState.value.fee as? SendFee.OnChain)?.value?.toULong() ?: return + } else { + lightningRepo.calculateTotalFee( + amountSats = amountSats, + address = _sendUiState.value.address, + speed = _sendUiState.value.speed, + utxosToSpend = _sendUiState.value.selectedUtxos, + ).getOrNull() ?: return + } if ( totalFee > BigDecimal.valueOf( @@ -2860,17 +3125,7 @@ class AppViewModel @Inject constructor( private suspend fun proceedWithPayment(contactPaymentContext: ContactPaymentContext?) { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable - if (!validateIncomingPaymentRequest(contactPaymentContext)) return - - consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { - handlePaymentPreparationFailure(contactPaymentContext, it) - return - } - - acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { - handlePaymentPreparationFailure(contactPaymentContext, it) - return - } + if (!prepareContactPayment(contactPaymentContext)) return val amount = _sendUiState.value.amount @@ -2930,7 +3185,7 @@ class AppViewModel @Inject constructor( val bolt11 = decodedInvoice.bolt11 val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount - val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL + val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount val tags = _sendUiState.value.selectedTags var createdMetadataPaymentId: String? = null @@ -2986,6 +3241,31 @@ class AppViewModel @Inject constructor( } } + private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { + if ( + contactPaymentContext != null && + synchronized(contactPaymentContextLock) { preparedContactPaymentContext == contactPaymentContext } + ) { + return true + } + if (!validateIncomingPaymentRequest(contactPaymentContext)) return false + + consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { + handlePaymentPreparationFailure(contactPaymentContext, it) + return false + } + acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + handlePaymentPreparationFailure(contactPaymentContext, it) + return false + } + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext == contactPaymentContext) { + preparedContactPaymentContext = contactPaymentContext + } + } + return true + } + private suspend fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true @@ -3104,13 +3384,15 @@ class AppViewModel @Inject constructor( val activityType = _transactionSheet.value.type.toActivityFilter() val txType = _transactionSheet.value.direction.toTxType() val paymentHashOrTxId = _transactionSheet.value.paymentHashOrTxId ?: return + val activityWalletId = _transactionSheet.value.activityWalletId ?: WalletScope.default _transactionSheet.update { it.copy(isLoadingDetails = true) } viewModelScope.launch { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, type = activityType, txType = txType, - retry = true + retry = activityWalletId == WalletScope.default, + walletId = activityWalletId, ).onSuccess { activity -> hideNewTransactionSheet() _transactionSheet.update { it.copy(isLoadingDetails = false) } @@ -3128,13 +3410,15 @@ class AppViewModel @Inject constructor( val activityType = _successSendUiState.value.type.toActivityFilter() val txType = _successSendUiState.value.direction.toTxType() val paymentHashOrTxId = _successSendUiState.value.paymentHashOrTxId ?: return + val activityWalletId = _successSendUiState.value.activityWalletId ?: WalletScope.default _successSendUiState.update { it.copy(isLoadingDetails = true) } viewModelScope.launch { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHashOrTxId, type = activityType, txType = txType, - retry = true + retry = activityWalletId == WalletScope.default, + walletId = activityWalletId, ).onSuccess { activity -> hideSheet() _successSendUiState.update { it.copy(isLoadingDetails = false) } @@ -3224,7 +3508,11 @@ class AppViewModel @Inject constructor( // refresh in background viewModelScope.launch(bgDispatcher) { // preselect utxos for deterministic fee estimation - if (settingsStore.data.first().coinSelectAuto && currentState.selectedUtxos == null) { + if ( + currentState.hardwareWalletId == null && + settingsStore.data.first().coinSelectAuto && + currentState.selectedUtxos == null + ) { lightningRepo.getFeeRateForSpeed(currentState.speed, currentState.feeRates) .mapCatching { satsPerVByte -> lightningRepo.determineUtxosToSpend( @@ -3331,6 +3619,18 @@ class AppViewModel @Inject constructor( private suspend fun getFeeEstimate(speed: TransactionSpeed? = null): Long { val currentState = _sendUiState.value + val hardwareWalletId = currentState.hardwareWalletId + if (hardwareWalletId != null) { + val selectedSpeed = speed ?: currentState.speed + val satsPerVByte = currentState.feeRates?.getSatsPerVByteFor(selectedSpeed) ?: 0u + if (satsPerVByte == 0u) return 0 + return hwWalletRepo.estimateFundingMiningFee( + walletId = hardwareWalletId, + address = currentState.address, + sats = currentState.amount, + satsPerVByte = satsPerVByte.toULong(), + ).getOrNull()?.toLong() ?: 0 + } return lightningRepo.calculateTotalFee( amountSats = currentState.amount, address = currentState.address, @@ -3343,6 +3643,7 @@ class AppViewModel @Inject constructor( suspend fun resetSendState( contactPaymentProfile: PubkyProfile? = null, isPaymentRequest: Boolean = false, + hardwareWalletId: String? = activeHardwareWalletId, ) { addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed @@ -3358,6 +3659,13 @@ class AppViewModel @Inject constructor( feeRates = rates, contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, + hardwareWalletId = hardwareWalletId, + hardwareWalletName = hardwareWalletId?.let { walletId -> + hwWalletRepo.wallets.value.find { it.id == walletId }?.name + }, + hardwareAvailableSats = hardwareWalletId?.let { walletId -> + hardwareEstimatedAvailable(walletId, speed, rates) + } ?: 0uL, ) } } @@ -3707,6 +4015,11 @@ class AppViewModel @Inject constructor( } isSubmittingPaymentRequest = contactPaymentContext?.incomingPaymentRequest != null + if (_sendUiState.value.hardwareWalletId != null) { + _sendUiState.update { it.copy(shouldConfirmPay = false) } + setSendEffect(SendEffect.NavigateToHardwareSign) + return + } viewModelScope.launch { try { _sendUiState.update { it.copy(shouldConfirmPay = false) } @@ -3717,13 +4030,27 @@ class AppViewModel @Inject constructor( } } - fun onSendSuccess(details: NewTransactionSheetDetails, allowDuplicateHash: Boolean = false) { + suspend fun prepareHardwareContactPayment(): Boolean { + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + return prepareContactPayment(contactPaymentContext) + } + + fun onHardwareSignCancelled() { + isSubmittingPaymentRequest = false + } + + fun onSendSuccess( + details: NewTransactionSheetDetails, + allowDuplicateHash: Boolean = false, + walletId: String = WalletScope.default, + navigate: Boolean = true, + ) { details.paymentHashOrTxId?.let { val isNewPayment = synchronized(processedPaymentsLock) { processedPayments.add(it) } when { - isNewPayment -> syncContactForActivity(it) + isNewPayment -> syncContactForActivity(it, walletId) !allowDuplicateHash -> { Logger.debug("Skipped duplicate processed payment '$it'", context = TAG) return @@ -3732,23 +4059,31 @@ class AppViewModel @Inject constructor( } _successSendUiState.update { details } - setSendEffect(SendEffect.PaymentSuccess) + if (navigate) setSendEffect(SendEffect.PaymentSuccess) } - private fun syncContactForActivity(paymentHashOrTxId: String) { + private fun syncContactForActivity( + paymentHashOrTxId: String, + walletId: String = WalletScope.default, + ) { val contactContext = synchronized(contactPaymentContextLock) { val pendingContext = pendingContactPaymentContexts.remove(paymentHashOrTxId) val context = pendingContext ?: activeContactPaymentContext if (pendingContext == null && context != null) { activeContactPaymentContext = null } + if (preparedContactPaymentContext == context) preparedContactPaymentContext = null context - } ?: return + } + isSubmittingPaymentRequest = false + contactContext ?: return viewModelScope.launch { activityRepo.setContact( contactPublicKey = contactContext.publicKey, forPaymentId = paymentHashOrTxId, + syncLdkPayments = walletId == WalletScope.default, + walletId = walletId, ) } } @@ -4002,6 +4337,8 @@ class AppViewModel @Inject constructor( private const val TEN_USD = 10 private const val MAX_BALANCE_FRACTION = 0.5 private const val MAX_FEE_AMOUNT_RATIO = 0.5 + private const val HW_SEND_FALLBACK_TX_VBYTES = 1_200uL + private const val HW_SEND_FALLBACK_SATS_PER_VBYTE = 3uL private val SCREEN_TRANSITION_DELAY = TRANSITION_SCREEN_MS.milliseconds private const val MIGRATION_LOADING_TIMEOUT_MS = 120_000L private const val POST_RESTORE_PRUNE_DELAY_MS = 30_000L @@ -4072,6 +4409,7 @@ data class SendUiState( val isAmountInputValid: Boolean = false, val isUnified: Boolean = false, val canSwitchWallet: Boolean = false, + val canSwitchFundingSource: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: ImmutableList = persistentListOf(), val decodedInvoice: LightningInvoice? = null, @@ -4090,6 +4428,9 @@ data class SendUiState( val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, + val hardwareWalletId: String? = null, + val hardwareWalletName: String? = null, + val hardwareAvailableSats: ULong = 0uL, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -4107,6 +4448,12 @@ sealed class SendFee(open val value: Long) { enum class SendMethod { ONCHAIN, LIGHTNING } +private sealed interface SendFundingSource { + data object Spending : SendFundingSource + data object Savings : SendFundingSource + data class Hardware(val walletId: String) : SendFundingSource +} + data class ContactPaymentContext( val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext? = null, @@ -4126,6 +4473,7 @@ sealed class SendEffect { data object NavigateToAmount : SendEffect() data object NavigateToScan : SendEffect() data object NavigateToConfirm : SendEffect() + data object NavigateToHardwareSign : SendEffect() data object NavigateToWithdrawConfirm : SendEffect() data object NavigateToWithdrawError : SendEffect() data object NavigateToCoinSelection : SendEffect() diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index d3e3f97a9e..a993616619 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -48,6 +48,7 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.isBroadcastConnectivityFailure import to.bitkit.ext.isTrezorDeviceBusy import to.bitkit.ext.isTrezorFirmwareError +import to.bitkit.ext.isTrezorSessionFailure import to.bitkit.ext.isTrezorUserCancellation import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toUserMessage @@ -1197,26 +1198,36 @@ class TransferViewModel @Inject constructor( throw HardwareFundingError(it) } - @Suppress("ThrowsCount") private suspend fun signHardwareFunding( walletId: String, funding: HwFundingTransaction, ): HwFundingSignedTx { - return runCatching { - withTimeout(HW_SIGN_TIMEOUT) { - hwWalletRepo.signFunding( - walletId = walletId, - funding = funding, - ).getOrThrow() - } - }.getOrElse { - it.rethrowIfCancellation() - if (it is TimeoutCancellationException) { - hwWalletRepo.disconnectStaleSession(walletId) - throw HardwareSigningTimeoutError(it) - } - throw it + val firstAttempt = runSuspendCatching { signHardwareFundingOnce(walletId, funding) } + val error = firstAttempt.exceptionOrNull() ?: return firstAttempt.getOrThrow() + if (!error.isTrezorSessionFailure()) throw error + + ensureHardwareConnected(walletId) + return signHardwareFundingOnce(walletId, funding) + } + + @Suppress("ThrowsCount") + private suspend fun signHardwareFundingOnce( + walletId: String, + funding: HwFundingTransaction, + ): HwFundingSignedTx = runCatching { + withTimeout(HW_SIGN_TIMEOUT) { + hwWalletRepo.signFunding( + walletId = walletId, + funding = funding, + ).getOrThrow() + } + }.getOrElse { + it.rethrowIfCancellation() + if (it is TimeoutCancellationException) { + hwWalletRepo.disconnectStaleSession(walletId) + throw HardwareSigningTimeoutError(it) } + throw it } private suspend fun broadcastHardwareFunding( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c8ee5de00..2e05b0e87e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,7 +198,7 @@ Could not open the passphrase wallet. Make sure your hardware device is unlocked and try again. Enter <accent>passphrase</accent> That passphrase opens a different wallet. Enter the one you paired this wallet with. - Enter the passphrase of this wallet so your hardware device can sign the transfer. + Enter the passphrase of this wallet so your hardware device can sign the transaction. Passphrase <accent>funds found</accent> Bitkit found funds behind a passphrase, and added these to your wallet balance. If you have funds protected by a passphrase, enter it below to add these funds to your wallet balance as well. @@ -211,6 +211,11 @@ Could not keep this wallet\'s tags in your backup. Try again, or remove it without keeping them. Could not rename the hardware wallet. Please try again. Could not search for hardware wallets. Check your connection and try again. + TO ADDRESS (CONFIRM ON DEVICE) + Trezor can only send to a Bitcoin address from this wallet. + Bitcoin address required + Open Trezor Connect + Sign With Device Funds transfer to savings is usually instant, but settlement may take up to <accent>14 days</accent> under certain network conditions. Funds\n<accent>availability</accent> Balance diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index 0fbac4f234..a0b65aef4a 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -1,10 +1,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.TrezorException +import org.junit.Test import to.bitkit.utils.AppError import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.junit.Test class TrezorExceptionExtTest { @Test @@ -58,4 +58,17 @@ class TrezorExceptionExtTest { assertFalse(AppError("Firmware error").isTrezorFirmwareError()) assertFalse(AppError("Device error (code 98): Firmware error").isTrezorFirmwareError()) } + + @Test + fun `isTrezorSessionFailure recognizes broken THP channel`() { + val error = AppError(TrezorException.ProtocolException("THP decryption error: aead::Error")) + + assertTrue(error.isTrezorSessionFailure()) + } + + @Test + fun `isTrezorSessionFailure rejects normal protocol failures`() { + assertFalse(TrezorException.ProtocolException("invalid PSBT").isTrezorSessionFailure()) + assertFalse(TrezorException.UserCancelled().isTrezorSessionFailure()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index bf8bec153e..f9413c2868 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -432,7 +432,7 @@ class ActivityRepoTest : BaseUnitTest() { whenever(coreService.activity.getTxIdsInBoostTxIds(WalletScope.default)).thenReturn(setOf(replacedTxId)) whenever( coreService.activity.get( - walletId = WalletScope.default, + walletId = null, filter = ActivityFilter.ALL, txType = null, tags = null, diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 85424a167a..836a7b0183 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ComposeOutput import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType @@ -1738,6 +1739,41 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(2uL, result.getOrThrow().satsPerVByte) } + @Test + fun `maxSpendableFunding subtracts the fee from a send-max compose`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever( + trezorRepo.composeTransactionOffline( + extendedKey = any(), + outputs = eq(listOf(ComposeOutput.SendMax(address = "bc1qtest"))), + feeRates = eq(listOf(2.0f)), + network = any(), + accountType = anyOrNull(), + coinSelection = any(), + ) + ).thenReturn( + Result.success( + listOf( + ComposeResult.Success( + psbt = "psbt", + fee = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + ) + ) + ) + ) + val sut = createRepo() + + val result = sut.maxSpendableFunding( + walletId = HARDWARE_WALLET_ID, + address = "bc1qtest", + satsPerVByte = 2uL, + ) + + assertEquals(25_000uL, result.getOrThrow()) + } + @Test fun `composeFundingTransaction does not sign when compose fails`() = test { whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) @@ -1834,7 +1870,7 @@ class HwWalletRepoTest : BaseUnitTest() { } @Test - fun `signFunding disconnects stale session when sign fails`() = test { + fun `signFunding disconnects stale session when THP channel fails`() = test { val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = 1_250uL, @@ -1843,7 +1879,7 @@ class HwWalletRepoTest : BaseUnitTest() { satsPerVByte = 2uL, ) whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) - .thenReturn(Result.failure(AppError("sign failed"))) + .thenReturn(Result.failure(TrezorException.ProtocolException("THP decryption error: aead::Error"))) whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) val sut = createRepo() @@ -1855,6 +1891,26 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).broadcastRawTx(any()) } + @Test + fun `signFunding keeps session for non-session signing error`() = 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("invalid PSBT"))) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val sut = createRepo() + + val result = sut.signFunding(HARDWARE_WALLET_ID, funding) + + assertEquals(true, result.isFailure) + verify(trezorRepo, never()).disconnectStaleSession(any()) + } + @Test fun `signFunding keeps session when user cancels on device`() = test { val funding = HwFundingTransaction( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 07d6156208..dc176ccb1f 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -1109,7 +1109,11 @@ class TrezorRepoTest : BaseUnitTest() { val features = mockFeatures() val device = mockDeviceInfo() whenever(trezorService.connect(eq(DEVICE_ID), any())) - .thenThrow(RuntimeException("thp timeout")) + .thenAnswer { + throw TrezorException.ProtocolException( + "THP decryption error: Channel mismatch: expected [73, cb], got [73, ca]" + ) + } .thenReturn(features) whenever(trezorService.scan()).thenReturn(listOf(device)) sut = createSut() @@ -1120,6 +1124,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) + verify(trezorService).disconnect() + verify(trezorTransport).disconnectDevice(DEVICE_ID) } @Test @@ -1134,7 +1140,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertNull(sut.state.value.connected) verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) - verify(trezorService).disconnect() + verify(trezorService, times(2)).disconnect() + verify(trezorTransport, times(2)).disconnectDevice(DEVICE_ID) } @Test @@ -1604,6 +1611,27 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(electrumServer, params.firstValue.wallet.electrumUrl) } + @Test + fun `offline compose should not read the device fingerprint`() = test { + whenever(trezorService.composeTransaction(any())).thenReturn(emptyList()) + sut = createSut() + + val result = sut.composeTransactionOffline( + 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, never()).getDeviceFingerprint() + verify(trezorService).composeTransaction(params.capture()) + assertNull(params.firstValue.wallet.fingerprint) + } + // endregion // region hasKnownDevices @@ -1760,6 +1788,17 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, never()).disconnect() } + @Test + fun `disconnectStaleSession should close transport when core disconnect fails`() = test { + whenever(trezorService.disconnect()).thenThrow(RuntimeException("disconnect failed")) + sut = createSut() + + val result = sut.disconnectStaleSession(DEVICE_ID) + + assertTrue(result.isFailure) + verify(trezorTransport).disconnectDevice(DEVICE_ID) + } + @Test fun `connectKnownDevice failure should not disconnect unrelated connected device`() = test { val otherDeviceId = "device-other" diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt index b90c7f85ea..cd1e9561c6 100644 --- a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -50,6 +50,43 @@ class CoreServiceTest { assertFalse(result.toDelete.single().v1.isTransfer) } + @Test + fun `merge hw snapshot keeps locally created pending send until watcher reports it`() { + val pendingSend = activity(id = "pendingSend").let { + Activity.Onchain( + it.v1.copy( + txType = PaymentType.SENT, + confirmed = false, + createdAt = 100_000uL, + ) + ) + } + + val result = mergePlan( + existing = listOf(pendingSend), + incoming = emptyList(), + ) + + assertTrue(result.toDelete.isEmpty()) + } + + @Test + fun `merge hw snapshot deletes expired pending send missing from snapshot`() { + val pendingSend = activity(id = "pendingSend").let { + Activity.Onchain( + it.v1.copy( + txType = PaymentType.SENT, + confirmed = false, + createdAt = 1uL, + ) + ) + } + + val result = mergePlan(existing = listOf(pendingSend), incoming = emptyList()) + + assertEquals(listOf(pendingSend), result.toDelete) + } + @Test fun `merge hw snapshot recovers transfer from known funding tx when no stored row remains`() { val result = mergePlan( @@ -89,6 +126,16 @@ class CoreServiceTest { assertEquals("stored-channel", merged?.channelId) } + @Test + fun `merge hw snapshot keeps stored contact`() { + val result = mergePlan( + existing = listOf(activityWithContact(id = "tx", contact = "pubky-contact")), + incoming = listOf(activity(id = "tx")), + ) + + assertEquals("pubky-contact", result.upserted("tx")?.contact) + } + @Test fun `merge hw snapshot fills missing channel id on stored transfer`() { val result = mergePlan( @@ -140,10 +187,12 @@ class CoreServiceTest { private fun mergePlan( existing: List, incoming: List, + currentTimestamp: ULong = 100_000uL, transferChannelIdsByFundingTxId: Map = emptyMap(), ) = mergeHwSnapshot( existing = existing, incoming = incoming, + currentTimestamp = currentTimestamp, transferChannelIdsByFundingTxId = transferChannelIdsByFundingTxId, ) @@ -172,6 +221,10 @@ class CoreServiceTest { ) ) + private fun activityWithContact(id: String, contact: String) = Activity.Onchain( + activity(id).v1.copy(contact = contact) + ) + private fun lightningActivity(id: String) = Activity.Lightning( LightningActivity.create( walletId = "hardware-wallet", diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt new file mode 100644 index 0000000000..7d7c85e14f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -0,0 +1,233 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import com.synonym.bitkitcore.BroadcastException +import com.synonym.bitkitcore.TrezorException +import com.synonym.bitkitcore.TrezorFeatures +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingSignedTx +import to.bitkit.models.HwFundingTransaction +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.services.ActivityService +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HwSendViewModelTest : BaseUnitTest() { + + private val context = mock() + private val hwWalletRepo = mock() + private val preActivityMetadataRepo = mock() + private val coreService = mock() + private val activityService = mock() + private val activityRepo = mock() + + private lateinit var sut: HwSendViewModel + + @Before + fun setUp() { + whenever(coreService.activity).thenReturn(activityService) + sut = HwSendViewModel( + context = context, + hwWalletRepo = hwWalletRepo, + preActivityMetadataRepo = preActivityMetadataRepo, + coreService = coreService, + activityRepo = activityRepo, + ) + } + + @Test + fun `signing reconnects and retries once after THP channel failure`() = test { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_000uL, + feeRate = 2.0f, + totalSpent = 26_000uL, + satsPerVByte = 2uL, + ) + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = funding.miningFeeSats, + feeRate = 2uL, + totalSpent = funding.totalSpent, + ) + val broadcast = HwFundingBroadcastResult( + txId = "txid", + miningFeeSats = signedTx.miningFeeSats, + feeRate = signedTx.feeRate, + totalSpent = signedTx.totalSpent, + ) + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false) + whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(mock())) + whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE)) + .thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn( + Result.failure(TrezorException.ProtocolException("THP decryption error: aead::Error")), + Result.success(signedTx), + ) + whenever(hwWalletRepo.broadcastFunding(signedTx)).thenReturn(Result.success(broadcast)) + + sut.signAndBroadcast( + HwSendRequest( + walletId = WALLET_ID, + address = ADDRESS, + amountSats = AMOUNT_SATS, + satsPerVByte = SATS_PER_VBYTE, + tags = emptyList(), + ) + ) + advanceUntilIdle() + + verify(hwWalletRepo, times(2)).ensureConnected(WALLET_ID) + verify(hwWalletRepo, times(2)).signFunding(WALLET_ID, funding) + verify(hwWalletRepo).broadcastFunding(signedTx) + verify(activityService).createSentOnchainActivityFromSendResult( + txid = broadcast.txId, + address = ADDRESS, + amount = AMOUNT_SATS, + fee = broadcast.miningFeeSats, + feeRate = broadcast.feeRate, + isTransfer = false, + channelId = null, + walletId = WALLET_ID, + ) + assertFalse(sut.uiState.value.isSigning) + } + + @Test + fun `contact preparation runs after signing and only once across broadcast retry`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.ElectrumException("connection failed")), + Result.success(fixture.broadcast), + ) + var preparationCalls = 0 + val prepareContactPayment: suspend () -> Boolean = { + verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) + preparationCalls += 1 + true + } + + sut.signAndBroadcast(request(), prepareContactPayment) + advanceUntilIdle() + + assertEquals(1, preparationCalls) + assertTrue(sut.uiState.value.hasPendingBroadcast) + + sut.signAndBroadcast(request(), prepareContactPayment) + advanceUntilIdle() + + assertEquals(1, preparationCalls) + verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + sut.completeBroadcast() + assertFalse(sut.uiState.value.hasPendingBroadcast) + } + + @Test + fun `passphrase reconnect keeps contact preparation before broadcast`() = test { + val fixture = stubSuccessfulPayment() + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(true, false) + whenever(hwWalletRepo.reconnectWithPassphrase(WALLET_ID, "hidden wallet")) + .thenReturn(Result.success(Unit)) + var preparationCalls = 0 + val prepareContactPayment: suspend () -> Boolean = { + preparationCalls += 1 + true + } + + sut.signAndBroadcast(request(), prepareContactPayment) + advanceUntilIdle() + assertTrue(sut.uiState.value.isPassphraseRequired) + + sut.submitPassphrase(request(), "hidden wallet", prepareContactPayment) + advanceUntilIdle() + + assertEquals(1, preparationCalls) + verify(hwWalletRepo).broadcastFunding(fixture.signedTx) + assertFalse(sut.uiState.value.isPassphraseRequired) + } + + @Test + fun `broadcast result survives collector reattachment until acknowledged`() = test { + val fixture = stubSuccessfulPayment() + + sut.signAndBroadcast(request()) + advanceUntilIdle() + + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + + sut.completeBroadcast() + + assertFalse(sut.uiState.value.isBroadcastUnresolved) + } + + private suspend fun stubSuccessfulPayment(): PaymentFixture { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_000uL, + feeRate = 2.0f, + totalSpent = 26_000uL, + satsPerVByte = SATS_PER_VBYTE, + ) + val signedTx = HwFundingSignedTx( + serializedTx = "rawtx", + miningFeeSats = funding.miningFeeSats, + feeRate = SATS_PER_VBYTE, + totalSpent = funding.totalSpent, + ) + val broadcast = HwFundingBroadcastResult( + txId = "txid", + miningFeeSats = signedTx.miningFeeSats, + feeRate = signedTx.feeRate, + totalSpent = signedTx.totalSpent, + ) + whenever(hwWalletRepo.needsPassphrase(WALLET_ID)).thenReturn(false) + whenever(hwWalletRepo.ensureConnected(WALLET_ID)).thenReturn(Result.success(mock())) + whenever(hwWalletRepo.composeFundingTransaction(WALLET_ID, ADDRESS, AMOUNT_SATS, SATS_PER_VBYTE)) + .thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(WALLET_ID, funding)).thenReturn(Result.success(signedTx)) + whenever(hwWalletRepo.broadcastFunding(signedTx)).thenReturn(Result.success(broadcast)) + return PaymentFixture(funding, signedTx, broadcast) + } + + private fun request() = HwSendRequest( + walletId = WALLET_ID, + address = ADDRESS, + amountSats = AMOUNT_SATS, + satsPerVByte = SATS_PER_VBYTE, + tags = emptyList(), + ) + + private data class PaymentFixture( + val funding: HwFundingTransaction, + val signedTx: HwFundingSignedTx, + val broadcast: HwFundingBroadcastResult, + ) + + private companion object { + const val WALLET_ID = "hardware-wallet" + const val ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + const val AMOUNT_SATS = 25_000uL + const val SATS_PER_VBYTE = 2uL + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3dc9a7ac11..edec27ed2f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -64,6 +64,7 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -185,6 +186,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val balanceState = MutableStateFlow(BalanceState()) private val hwReceivedTxs = MutableSharedFlow() + private val hwWallets = MutableStateFlow(persistentListOf()) private val needsPairingCode = MutableStateFlow(false) private val pairingCodeRequestId = MutableStateFlow(null) private val settingsData = MutableStateFlow(SettingsData()) @@ -203,6 +205,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { timedSheetType.value = null + hwWallets.value = persistentListOf() stubRepositories() sut = createViewModel() } @@ -223,6 +226,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.nodeEventUpdates).thenReturn(nodeEventUpdates) whenever(lightningRepo.nodeEvents).thenReturn(nodeEventUpdates.map { it.event }) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) + whenever(hwWalletRepo.wallets).thenReturn(hwWallets) whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(coreService.activity).thenReturn(activityService) @@ -1713,6 +1717,84 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(before, sut.sendUiState.value.payMethod) } + @Test + fun `normal onchain send can switch from savings to Trezor`() = test { + val exactAvailableStarted = CompletableDeferred() + val finishExactAvailable = CompletableDeferred() + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 50_000uL)) + whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) } + .doSuspendableAnswer { + exactAvailableStarted.complete(Unit) + finishExactAvailable.await() + Result.success(48_000uL) + } + balanceState.value = BalanceState(maxSendOnchainSats = 10_000uL) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000uL, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ) + ) + sut.setSendEvent(SendEvent.AmountChange(1_000uL)) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.canSwitchFundingSource) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + exactAvailableStarted.await() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals("Trezor", sut.sendUiState.value.hardwareWalletName) + assertEquals(46_400uL, sut.sendUiState.value.hardwareAvailableSats) + + finishExactAvailable.complete(Unit) + advanceUntilIdle() + + assertEquals(48_000uL, sut.sendUiState.value.hardwareAvailableSats) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + + @Test + fun `amount continue shows loading and ignores duplicate request`() = test { + val estimateStarted = CompletableDeferred() + val finishEstimate = CompletableDeferred() + whenever { hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any()) }.doSuspendableAnswer { + estimateStarted.complete(Unit) + finishEstimate.await() + Result.success(250uL) + } + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000uL, + isAmountInputValid = true, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareAvailableSats = 50_000uL, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ) + ) + + sut.setSendEvent(SendEvent.AmountContinue) + sut.setSendEvent(SendEvent.AmountContinue) + estimateStarted.await() + + assertTrue(sut.sendUiState.value.isLoading) + + finishEstimate.complete(Unit) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.isLoading) + verify(hwWalletRepo, times(1)).estimateFundingMiningFee( + HARDWARE_WALLET_ID, + REGTEST_ADDRESS, + 1_000uL, + 3uL, + ) + } + @Test fun `pending contact lightning success tags activity`() = test { val contactKey = "pubkycontact" @@ -3315,6 +3397,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `approved hardware payment request preparation is idempotent`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ) + ) + + assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment()) + + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + } + + @Test + fun `private hardware contact preparation is idempotent`() = test { + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext) + + assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment()) + + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + } + @Test fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" @@ -3987,6 +4107,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { method.invoke(sut) } + private fun hardwareWallet(fundingBalanceSats: ULong) = HwWallet( + id = HARDWARE_WALLET_ID, + name = "Trezor", + model = "Safe 7", + transportType = TransportType.USB, + isConnected = false, + balanceSats = fundingBalanceSats, + activities = persistentListOf(), + fundingBalanceSats = fundingBalanceSats, + ) + private fun paymentRequest() = PaykitPaymentRequest( paymentRequestId = "request-id", counterparty = testPublicKey, @@ -4000,3 +4131,5 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private const val SAMROCK_SETUP_URL = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" +private const val HARDWARE_WALLET_ID = "trezor:wallet" +private const val REGTEST_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index bd7ef7ff7c..e0e0214aeb 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1232,6 +1232,44 @@ class TransferViewModelTest : BaseUnitTest() { verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) } + @Test + fun `onTransferToSpendingHwConfirm reconnects and retries after THP channel failure`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = funding.totalSpent, + ) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn( + Result.failure(AppError(TrezorException.ProtocolException("THP decryption error: aead::Error"))), + Result.success(signed), + ) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) + + sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + advanceUntilIdle() + + verify(hwWalletRepo, times(2)).ensureConnected(HARDWARE_WALLET_ID) + verify(hwWalletRepo, times(2)).signFunding(HARDWARE_WALLET_ID, funding) + verify(hwWalletRepo).broadcastFunding(signed) + verify(cacheStore).addPaidOrder(order.id, TXID) + } + @Test fun `onTransferToSpendingHwConfirm asks for the passphrase when the hidden wallet session is gone`() = test { val order = previewBtOrder() diff --git a/changelog.d/next/1187.added.md b/changelog.d/next/1187.added.md new file mode 100644 index 0000000000..07e0e0dc6a --- /dev/null +++ b/changelog.d/next/1187.added.md @@ -0,0 +1 @@ +Added on-chain send support for paired Trezor wallets, including transaction approval on the device. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 248acf8062..290f816246 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -61,7 +61,8 @@ instead of UI interactions. Run in this order — `connect-home-tile.xml` pairs the emulator that the later journeys rely on, `suggestion-intro-sheet.xml`, `connect-flow.xml` and `settings-hardware-wallets.xml` each end by re-pairing after a forget, and `detail-overview.xml` runs last because its final -Remove step forgets the device. The `passphrase-*` journeys run as a block after +Remove step forgets the device. Run the send journey while the paired native-segwit account is +funded. The `passphrase-*` journeys run as a block after `connect-home-tile.xml`, in the order listed: `passphrase-pairing.xml` pairs the hidden wallet the other three rely on, and `passphrase-settings-remove.xml` removes it again. @@ -78,6 +79,7 @@ the other three rely on, and `passphrase-settings-remove.xml` removes it again. | `transfer-to-spending.xml` | Happy-path transfer plus one scoped hardware Transfer activity | | `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 | +| `send-onchain.xml` | Normal Send flow funded by Trezor: source selection, guarded preparation, device signing, broadcast, success, and activity | | `passphrase-pairing.xml` | Passphrase button on Paired → Enter Passphrase → Passphrase Funds Found; second home tile, own label, no passphrase in logs | | `passphrase-duplicate.xml` | Re-entering a watched passphrase reports "already added" and adds no tile | | `passphrase-settings-remove.xml` | Per-identity settings row, rename and delete; removing the hidden wallet keeps the device paired | @@ -101,6 +103,10 @@ Passphrase testTags: `HardwareWalletPairedPassphrase`, `HardwareWalletPassphrase sign screen `HwTransferPassphraseSheet`, `HwTransferPassphraseInput`, `HwTransferPassphraseCancel`, `HwTransferPassphraseContinue`. +Send testTags: `Send`, `RecipientManual`, `RecipientInput`, `AddressContinue`, +`send_amount_screen`, `AssetButton-switch`, `ContinueAmount`, `SendConfirmAssetButton`, +`HardwareSendAmount`, `HardwareSendAddress`, `HardwareSendOpenTrezorConnect`, and `SendSuccess`. + The current Connect Hardware sheet starts USB discovery immediately after Continue. BLE is included only once Android nearby-devices permission is granted and Bluetooth is enabled. The sheet has no internal back navigation; Android back dismisses the sheet. diff --git a/journeys/hardware-wallet/send-onchain.xml b/journeys/hardware-wallet/send-onchain.xml new file mode 100644 index 0000000000..b73b9fc903 --- /dev/null +++ b/journeys/hardware-wallet/send-onchain.xml @@ -0,0 +1,60 @@ + + + Sends a normal on-chain payment from a paired Trezor through Bitkit's standard Send flow, + including funding-source selection, transaction preparation, device signing, broadcast, and + success handling. Requires a paired Bridge emulator whose native-segwit account holds + spendable regtest funds and a valid regtest destination address. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the Send button (testTag "Send") + + + If a camera permission dialog appears, dismiss it by choosing "Don't allow" + + + Tap "Enter Manually" (testTag "RecipientManual"), type a valid regtest bitcoin address into + the recipient field (testTag "RecipientInput"), then tap Continue (testTag "AddressContinue") + + + Verify the Send amount screen is visible (testTag "send_amount_screen") + + + Tap the funding-source button (testTag "AssetButton-switch"), waiting for any balance loading + to finish between taps, until it shows the paired Trezor name in blue + + + Verify AVAILABLE shows a positive Trezor balance, then enter a valid amount smaller than it + with the number pad + + + Tap Continue once (testTag "ContinueAmount") and verify it shows a loading state and cannot + start transaction preparation again while the confirmation is being prepared + + + Verify the Send review screen opens and the FROM field (testTag "SendConfirmAssetButton") + shows the paired Trezor name + + + Swipe to confirm the payment + + + Verify the hardware sign screen opens, showing the amount (testTag "HardwareSendAmount"), the + destination address (testTag "HardwareSendAddress"), and the "Open Trezor Connect" button + (testTag "HardwareSendOpenTrezorConnect") + + + Tap "Open Trezor Connect" and approve every transaction prompt shown by the Bridge emulator + + + Verify the payment broadcasts and the success screen appears (testTag "SendSuccess") + + + Close the success screen, return to the wallet home screen, and verify the new payment appears + once in recent activity with the blue hardware-wallet icon + + + From 686479dadadcf306066664ac15a1579c7827a5e7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 12:57:46 -0500 Subject: [PATCH 2/4] fix: stabilize trezor send state --- .../to/bitkit/repositories/HwWalletRepo.kt | 14 +++- .../java/to/bitkit/services/CoreService.kt | 1 + .../to/bitkit/ui/components/SwipeToConfirm.kt | 8 +- .../screens/wallets/send/SendConfirmScreen.kt | 1 + .../java/to/bitkit/ui/sheets/SendSheet.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../bitkit/repositories/HwWalletRepoTest.kt | 32 +++++++ .../to/bitkit/services/CoreServiceTest.kt | 10 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 84 +++++++++++++++++++ 9 files changed, 149 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 8a4dd9f4e6..4a46ae3a88 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -665,7 +665,7 @@ class HwWalletRepo @Inject constructor( ) val snapshotCacheKey = snapshot.toCacheKey() lastPersistedHwSnapshots[walletId] - ?.takeIf { it.source == snapshotCacheKey } + ?.takeIf { it.source == snapshotCacheKey && !it.hasRetainedPendingSend() } ?.let { _watcherData.update { data -> data + (watcherId to watcher.copy(activities = it.activities)) @@ -989,3 +989,15 @@ private data class PersistedHwSnapshot( val source: HwSnapshot, val activities: ImmutableList, ) + +private fun PersistedHwSnapshot.hasRetainedPendingSend(): Boolean { + val sourceIds = source.activities.map { it.scopedId() }.toSet() + return activities.any { + val activity = (it as? Activity.Onchain)?.v1 ?: return@any false + it.scopedId() !in sourceIds && + activity.txType == PaymentType.SENT && + !activity.confirmed && + !activity.isTransfer && + activity.doesExist + } +} diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index e6fbe46259..ca79cfff71 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -309,6 +309,7 @@ private fun OnchainActivity.mergedWith(stored: OnchainActivity?): OnchainActivit channelId = channelId ?: stored.channelId, transferTxId = transferTxId ?: stored.transferTxId, contact = contact ?: stored.contact, + seenAt = seenAt ?: stored.seenAt, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt index 9d7f06b5f9..a610eeae10 100644 --- a/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt +++ b/app/src/main/java/to/bitkit/ui/components/SwipeToConfirm.kt @@ -70,6 +70,7 @@ fun SwipeToConfirm( icon: ImageVector = Icons.AutoMirrored.Default.ArrowForward, @DrawableRes endIcon: Int = R.drawable.ic_check, endIconTint: Color = Colors.Black, + enabled: Boolean = true, loading: Boolean = false, confirmed: Boolean = false, progress: MutableFloatState? = null, @@ -106,9 +107,10 @@ fun SwipeToConfirm( .requiredHeight(CircleSize + Padding * 2) .clip(CircleShape) .primaryButtonStyle( - isEnabled = !loading, + isEnabled = enabled && !loading, shape = CircleShape, ) + .alpha(if (enabled || loading) 1f else 0.5f) .padding(Padding) ) { Box( @@ -141,8 +143,8 @@ fun SwipeToConfirm( modifier = Modifier .offset { IntOffset(x = (panX.value.toDp() - InvisibleBorder).toPx().roundToInt(), y = 0) } .size(GrabSize) - .pointerInput(loading, confirmed) { - if (!loading && !confirmed) { + .pointerInput(enabled, loading, confirmed) { + if (enabled && !loading && !confirmed) { detectHorizontalDragGestures( onDragStart = { }, onHorizontalDrag = { _, dragAmount -> diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index f9ac40da94..321902744a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -389,6 +389,7 @@ private fun ContentRunning( SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), color = accentColor, + enabled = uiState.isAmountInputValid, loading = isLoading, confirmed = isLoading, progress = swipeProgress, diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 68039b1b34..893e5b1f7b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -37,6 +37,7 @@ import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.SendFailureDetails import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.components.ConnectionIssuesView @@ -217,7 +218,7 @@ fun SendSheet( SendAmountScreen( uiState = uiState, nodeLifecycleState = if (uiState.hardwareWalletId != null) { - to.bitkit.models.NodeLifecycleState.Running + NodeLifecycleState.Running } else { lightningState.nodeLifecycleState }, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c60be7863f..1932573a7d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3034,6 +3034,7 @@ class AppViewModel @Inject constructor( private fun onSwipeToPay() { Logger.debug("Swipe to pay event, checking send confirmation conditions", context = TAG) + if (!_sendUiState.value.isAmountInputValid) return viewModelScope.launch { val amount = _sendUiState.value.amount diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 836a7b0183..a376c99855 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -233,6 +233,38 @@ class HwWalletRepoTest : BaseUnitTest() { ) } + @Test + fun `unchanged watcher snapshot reevaluates a retained pending send`() = test { + val retainedPendingSend = watcherActivity( + amount = 100uL, + txType = PaymentType.SENT, + blockHeight = null, + confirmations = 0u, + ) + val event = transactionsChanged(total = 0uL) + whenever { + activityRepo.persistHwSnapshot( + HARDWARE_WALLET_ID, + event.activities, + event.transactionDetails, + ) + }.thenReturn( + Result.success(listOf(retainedPendingSend)), + Result.success(emptyList()), + ) + val sut = createRepo() + + watcherEvents.emit("hardware-wallet|nativeSegwit" to event) + watcherEvents.emit("hardware-wallet|nativeSegwit" to event) + + assertTrue(sut.activities.value.isEmpty()) + verify(activityRepo, times(2)).persistHwSnapshot( + walletId = HARDWARE_WALLET_ID, + activities = event.activities, + transactionDetails = event.transactionDetails, + ) + } + @Test fun `pending timestamp changes reuse snapshot until confirmation`() = test { val pendingActivity = watcherActivity( diff --git a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt index cd1e9561c6..cb1da2b8cb 100644 --- a/app/src/test/java/to/bitkit/services/CoreServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/CoreServiceTest.kt @@ -136,6 +136,16 @@ class CoreServiceTest { assertEquals("pubky-contact", result.upserted("tx")?.contact) } + @Test + fun `merge hw snapshot keeps stored seen timestamp`() { + val result = mergePlan( + existing = listOf(Activity.Onchain(activity(id = "tx").v1.copy(seenAt = 42uL))), + incoming = listOf(activity(id = "tx")), + ) + + assertEquals(42uL, result.upserted("tx")?.seenAt) + } + @Test fun `merge hw snapshot fills missing channel id on stored transfer`() { val result = mergePlan( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index edec27ed2f..4939ee0b4e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -11,6 +11,7 @@ import androidx.core.net.toUri import app.cash.turbine.test import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf @@ -1756,6 +1757,73 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) } + @Test + fun `invalid funding source cannot start confirmation`() = test { + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000uL, + isAmountInputValid = false, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareAvailableSats = 100_000uL, + payMethod = SendMethod.ONCHAIN, + ) + ) + + sut.setSendEvent(SendEvent.SwipeToPay) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `hardware send rejects scanned non-onchain payment requests`() = test { + val scans = nonOnchainPaymentScans() + whenever(context.getString(R.string.hardware__send_onchain_only_title)).thenReturn("On-chain only") + scans.forEach { (input, scan) -> + whenever { coreService.decode(input) }.thenReturn(scan) + } + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + clearInvocations(toastManager) + + scans.forEach { (input, _) -> + sut.onScanResult(input) + advanceUntilIdle() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + } + + @Test + fun `hardware send rejects pasted non-onchain payment requests`() = test { + val scans = nonOnchainPaymentScans() + whenever(context.getString(R.string.hardware__send_onchain_only_title)).thenReturn("On-chain only") + scans.forEach { (input, scan) -> + whenever { coreService.decode(input) }.thenReturn(scan) + } + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + clearInvocations(toastManager) + + scans.forEach { (input, _) -> + val clipData = mock() + val item = mock() + whenever(item.text).thenReturn(input) + whenever(clipData.getItemAt(0)).thenReturn(item) + whenever(clipboardManager.primaryClip).thenReturn(clipData) + + sut.setSendEvent(SendEvent.Paste) + advanceUntilIdle() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + } + @Test fun `amount continue shows loading and ignores duplicate request`() = test { val estimateStarted = CompletableDeferred() @@ -3928,6 +3996,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.canSend(amountSats)).thenReturn(true) } + private fun nonOnchainPaymentScans() = listOf( + "lnbcrt1hardware" to Scanner.Lightning(lightningInvoice("lnbcrt1hardware", 1_000uL)), + "lnurl1hardware" to Scanner.LnurlPay( + LnurlPayData( + uri = "lnurl1hardware", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000uL, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + ), + ) + private suspend fun stubOpenedPaymentRequest( request: PaykitPaymentRequest, paymentRequest: String, From 80d47e336abe4a6bb5af06f59dc4e33e6ad86ada Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 14:05:18 -0500 Subject: [PATCH 3/4] fix: validate fixed send amounts --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1932573a7d..fccc71bcf0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2594,6 +2594,7 @@ class AppViewModel @Inject constructor( val lnAmountSats = lnInvoice?.amountSatoshis ?: 0u if (lnAmountSats > 0u) { + _sendUiState.update { it.copy(isAmountInputValid = true) } Logger.info("Found amount in unified invoice, checking QuickPay conditions", context = TAG) val quickPayHandled = handleQuickPayIfApplicable( @@ -2761,6 +2762,7 @@ class AppViewModel @Inject constructor( amount = amount, addressInput = scanResult, isAddressInputValid = true, + isAmountInputValid = true, decodedInvoice = invoice, payMethod = SendMethod.LIGHTNING, ) @@ -2811,6 +2813,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( amount = initialAmount, + isAmountInputValid = initialAmount > 0uL, payMethod = SendMethod.LIGHTNING, lnurl = LnurlParams.LnurlPay(data), ) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4939ee0b4e..7f611d8573 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1776,6 +1776,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.shouldConfirmPay) } + @Test + fun `fixed lightning invoice has valid amount`() = test { + val bolt11 = "lnbcrt1fixedamount" + stubLightningScan(bolt11 = bolt11, amountSats = 1_000uL) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.isAmountInputValid) + } + + @Test + fun `fixed LNURL payment has valid amount`() = test { + val lnurl = "lnurl1fixedamount" + whenever { coreService.decode(lnurl) }.thenReturn( + Scanner.LnurlPay( + LnurlPayData( + uri = lnurl, + callback = "https://example.com/callback", + minSendable = 1_000_000uL, + maxSendable = 1_000_000uL, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + ) + ) + whenever(lightningRepo.canSend(1_000uL)).thenReturn(true) + + sut.onScanResult(lnurl) + advanceUntilIdle() + + assertTrue(sut.sendUiState.value.isAmountInputValid) + } + @Test fun `hardware send rejects scanned non-onchain payment requests`() = test { val scans = nonOnchainPaymentScans() From 0cf15dbc6479fe3db0a3ec81ead8ed73e56ba62a Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 27 Aug 2026 17:17:11 -0500 Subject: [PATCH 4/4] fix: stabilize funding source switching --- .../wallets/send/SendAmountContentTest.kt | 18 +++++++ .../ui/components/NumberPadActionButton.kt | 43 ++++++++++------- .../screens/wallets/send/SendAmountScreen.kt | 5 +- .../screens/wallets/send/SendConfirmScreen.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 48 +++++++++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 19 ++++++++ 6 files changed, 107 insertions(+), 30 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt index 6be28a235b..7ff13a2034 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendAmountContentTest.kt @@ -105,4 +105,22 @@ class SendAmountContentTest { composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() } + + @Test + fun whenFundingSourceSwitching_sourceAndContinueButtonsShouldBeDisabled() { + composeTestRule.setContent { + SendAmountContent( + nodeLifecycleState = nodeLifecycleState, + uiState = uiState.copy( + isAmountInputValid = true, + canSwitchFundingSource = true, + isSwitchingFundingSource = true, + ), + amountInputViewModel = previewAmountInputViewModel(), + ) + } + + composeTestRule.onNodeWithTag("AssetButton-switch").assertIsNotEnabled() + composeTestRule.onNodeWithTag("ContinueAmount").assertIsNotEnabled() + } } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt index cdb30dfd26..834cde45f7 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadActionButton.kt @@ -33,15 +33,17 @@ fun NumberPadActionButton( modifier: Modifier = Modifier, color: Color = Colors.Brand, enabled: Boolean = true, + isLoading: Boolean = false, @DrawableRes icon: Int? = null, ) { val contentPadding = PaddingValues(horizontal = 8.dp, vertical = 5.dp) val height = 28.dp val buttonShape = RoundedCornerShape(8.dp) - if (enabled) { + if (enabled || isLoading) { Button( onClick = onClick, + enabled = enabled && !isLoading, colors = AppButtonDefaults.primaryColors.copy( containerColor = Color.Transparent, disabledContainerColor = Color.Transparent @@ -51,27 +53,34 @@ fun NumberPadActionButton( modifier = modifier .requiredHeight(height) .primaryButtonStyle( - isEnabled = true, + isEnabled = enabled && !isLoading, shape = buttonShape, ) - .alphaFeedback(enabled = enabled) + .alphaFeedback(enabled = enabled && !isLoading) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (icon != null) { - Icon( - painter = painterResource(icon), - contentDescription = text, - tint = color, - modifier = Modifier.size(16.dp) + if (isLoading) { + GradientCircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp) + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) { + Icon( + painter = painterResource(icon), + contentDescription = text, + tint = color, + modifier = Modifier.size(16.dp) + ) + } + Caption13Up( + text = text, + color = color, ) } - Caption13Up( - text = text, - color = color, - ) } } } else { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index a9025ed6ec..1b40d76b0a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -266,7 +266,7 @@ private fun SendAmountNodeRunning( uiState.lnurl is LnurlParams.LnurlWithdraw -> R.string.wallet__lnurl_w_max uiState.hardwareWalletId != null -> R.string.wallet__send_available uiState.isUnified -> R.string.wallet__send_available - uiState.payMethod == SendMethod.ONCHAIN -> R.string.wallet__send_available_savings + uiState.payMethod == SendMethod.ONCHAIN -> R.string.wallet__send_available uiState.payMethod == SendMethod.LIGHTNING -> R.string.wallet__send_available_spending else -> R.string.wallet__send_available } @@ -328,7 +328,7 @@ private fun SendAmountNodeRunning( PrimaryButton( text = stringResource(R.string.common__continue), - enabled = uiState.isAmountInputValid, + enabled = uiState.isAmountInputValid && !uiState.isSwitchingFundingSource, isLoading = uiState.isLoading, onClick = onContinue, modifier = Modifier.testTag("ContinueAmount") @@ -365,6 +365,7 @@ private fun PaymentMethodButton( icon = if (uiState.canSwitchFundingSource) R.drawable.ic_transfer else null, onClick = onClick, enabled = uiState.canSwitchFundingSource && !uiState.isLoading, + isLoading = uiState.isSwitchingFundingSource, modifier = Modifier .height(28.dp) .testTag("AssetButton-$testId") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 321902744a..e1838efd1a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -389,7 +389,7 @@ private fun ContentRunning( SwipeToConfirm( text = stringResource(R.string.wallet__send_swipe), color = accentColor, - enabled = uiState.isAmountInputValid, + enabled = uiState.isAmountInputValid && !uiState.isSwitchingFundingSource, loading = isLoading, confirmed = isLoading, progress = swipeProgress, @@ -515,6 +515,7 @@ private fun OnChainDetails( }, color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, enabled = uiState.canSwitchFundingSource, + isLoading = uiState.isSwitchingFundingSource, icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, @@ -641,6 +642,7 @@ private fun LightningDetails( text = stringResource(R.string.wallet__spending__title), color = Colors.Purple, enabled = uiState.canSwitchFundingSource, + isLoading = uiState.isSwitchingFundingSource, icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fccc71bcf0..de7eedb136 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -283,11 +283,23 @@ class AppViewModel @Inject constructor( private val sendEvents = MutableSharedFlow() private var amountContinuePending = false + private var fundingSourceSwitchPending = false + private var onchainSendRefreshJob: Job? = null fun setSendEvent(event: SendEvent) { - if (event == SendEvent.AmountContinue) { - if (amountContinuePending) return - amountContinuePending = true + when (event) { + SendEvent.AmountContinue -> { + if (amountContinuePending) return + amountContinuePending = true + } + + SendEvent.PaymentMethodSwitch -> { + if (fundingSourceSwitchPending) return + fundingSourceSwitchPending = true + _sendUiState.update { it.copy(isSwitchingFundingSource = true) } + } + + else -> Unit } viewModelScope.launch { sendEvents.emit(event) } } @@ -1457,7 +1469,12 @@ class AppViewModel @Inject constructor( } finally { amountContinuePending = false } - SendEvent.PaymentMethodSwitch -> onPaymentMethodSwitch() + SendEvent.PaymentMethodSwitch -> try { + onPaymentMethodSwitch() + } finally { + fundingSourceSwitchPending = false + _sendUiState.update { state -> state.copy(isSwitchingFundingSource = false) } + } is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) @@ -1988,6 +2005,7 @@ class AppViewModel @Inject constructor( val current = _sendUiState.value val sources = availableFundingSources(current) if (sources.size < 2) return + onchainSendRefreshJob?.cancel() val selected = current.selectedFundingSource() val selectedIndex = sources.indexOf(selected).takeIf { it >= 0 } ?: 0 when (val nextSource = sources[(selectedIndex + 1) % sources.size]) { @@ -2013,11 +2031,12 @@ class AppViewModel @Inject constructor( hardwareWalletId = null, hardwareWalletName = null, hardwareAvailableSats = 0uL, + fee = null, selectedUtxos = null, confirmedWarnings = persistentListOf(), ) } - refreshOnchainSendIfNeeded() + refreshOnchainSendIfNeeded()?.join() } is SendFundingSource.Hardware -> selectHardwareFundingSource(nextSource.walletId, current) @@ -2040,6 +2059,7 @@ class AppViewModel @Inject constructor( hardwareWalletName = walletName, hardwareAvailableSats = initialAvailable, isAmountInputValid = it.amount > Defaults.dustLimit.toULong() && it.amount <= initialAvailable, + fee = null, selectedUtxos = null, confirmedWarnings = persistentListOf(), ) @@ -2049,7 +2069,7 @@ class AppViewModel @Inject constructor( if (it.hardwareWalletId != walletId) return@update it it.copy(hardwareAvailableSats = available) } - refreshOnchainSendIfNeeded() + refreshOnchainSendIfNeeded()?.join() } private suspend fun selectHardwareFundingSourceForAmount(amount: ULong): Boolean { @@ -3500,17 +3520,17 @@ class AppViewModel @Inject constructor( } /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ - private fun refreshOnchainSendIfNeeded() { + private fun refreshOnchainSendIfNeeded(): Job? { val currentState = _sendUiState.value if (currentState.payMethod != SendMethod.ONCHAIN || currentState.amount == 0uL || currentState.address.isEmpty() ) { - return + return null } - // refresh in background - viewModelScope.launch(bgDispatcher) { + onchainSendRefreshJob?.cancel() + val job = viewModelScope.launch(bgDispatcher, start = CoroutineStart.LAZY) { // preselect utxos for deterministic fee estimation if ( currentState.hardwareWalletId == null && @@ -3530,6 +3550,12 @@ class AppViewModel @Inject constructor( } refreshFeeEstimates() } + onchainSendRefreshJob = job + job.invokeOnCompletion { + if (onchainSendRefreshJob === job) onchainSendRefreshJob = null + } + job.start() + return job } private suspend fun refreshFeeEstimates() = withContext(bgDispatcher) { @@ -3670,6 +3696,7 @@ class AppViewModel @Inject constructor( hardwareAvailableSats = hardwareWalletId?.let { walletId -> hardwareEstimatedAvailable(walletId, speed, rates) } ?: 0uL, + isSwitchingFundingSource = fundingSourceSwitchPending, ) } } @@ -4414,6 +4441,7 @@ data class SendUiState( val isUnified: Boolean = false, val canSwitchWallet: Boolean = false, val canSwitchFundingSource: Boolean = false, + val isSwitchingFundingSource: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, val selectedTags: ImmutableList = persistentListOf(), val decodedInvoice: LightningInvoice? = null, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 7f611d8573..541ed9233e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test +import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlPayData @@ -1722,6 +1723,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `normal onchain send can switch from savings to Trezor`() = test { val exactAvailableStarted = CompletableDeferred() val finishExactAvailable = CompletableDeferred() + val feeEstimateStarted = CompletableDeferred() + val finishFeeEstimate = CompletableDeferred() hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 50_000uL)) whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) } .doSuspendableAnswer { @@ -1729,6 +1732,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishExactAvailable.await() Result.success(48_000uL) } + whenever { hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any()) } + .doSuspendableAnswer { + feeEstimateStarted.complete(Unit) + finishFeeEstimate.await() + Result.success(250uL) + } balanceState.value = BalanceState(maxSendOnchainSats = 10_000uL) setSendState( SendUiState( @@ -1736,6 +1745,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amount = 1_000uL, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + feeRates = FeeRates(fast = 5u, mid = 3u, slow = 1u), ) ) sut.setSendEvent(SendEvent.AmountChange(1_000uL)) @@ -1746,15 +1756,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PaymentMethodSwitch) exactAvailableStarted.await() + assertTrue(sut.sendUiState.value.isSwitchingFundingSource) assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) assertEquals("Trezor", sut.sendUiState.value.hardwareWalletName) assertEquals(46_400uL, sut.sendUiState.value.hardwareAvailableSats) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) finishExactAvailable.complete(Unit) + feeEstimateStarted.await() + + assertTrue(sut.sendUiState.value.isSwitchingFundingSource) + + finishFeeEstimate.complete(Unit) advanceUntilIdle() + assertFalse(sut.sendUiState.value.isSwitchingFundingSource) assertEquals(48_000uL, sut.sendUiState.value.hardwareAvailableSats) assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + verify(hwWalletRepo, times(1)).maxSpendableFunding(any(), any(), any()) } @Test