diff --git a/app/src/main/java/to/bitkit/data/HwWalletStore.kt b/app/src/main/java/to/bitkit/data/HwWalletStore.kt index 79f0cc58cc..08cd6ab191 100644 --- a/app/src/main/java/to/bitkit/data/HwWalletStore.kt +++ b/app/src/main/java/to/bitkit/data/HwWalletStore.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import to.bitkit.data.serializers.HwWalletDataSerializer import to.bitkit.di.IoDispatcher -import to.bitkit.repositories.KnownDevice +import to.bitkit.models.KnownDevice import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt index 72f6306252..2f67f711ec 100644 --- a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt +++ b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import to.bitkit.data.entities.TransferEntity @Dao +@Suppress("TooManyFunctions") interface TransferDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transfer: TransferEntity) @@ -35,6 +36,9 @@ interface TransferDao { @Query("SELECT * FROM transfers WHERE id = :id LIMIT 1") suspend fun getById(id: String): TransferEntity? + @Query("SELECT * FROM transfers WHERE fundingTxId = :fundingTxId LIMIT 1") + suspend fun getByFundingTxId(fundingTxId: String): TransferEntity? + @Query("UPDATE transfers SET isSettled = 1, settledAt = :settledAt WHERE id = :id") suspend fun markSettled(id: String, settledAt: Long) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 5478a48454..0b550823a6 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -263,6 +263,9 @@ object Defaults { /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u + /** Fallback fee percentage used when fee estimates are temporarily unavailable. */ + const val fallbackFeePercent = 0.1 + /** * Minimum value in sats for an output. Outputs below the dust limit may not be processed because the fees * required to include them in a block would be greater than the value of the transaction itself. diff --git a/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt b/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt new file mode 100644 index 0000000000..ab234c0ee6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TrezorTransportType.kt @@ -0,0 +1,9 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.models.TransportType + +fun TrezorTransportType.toTransportType(): TransportType = when (this) { + TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH + TrezorTransportType.USB -> TransportType.USB +} diff --git a/app/src/main/java/to/bitkit/models/HardwareWallet.kt b/app/src/main/java/to/bitkit/models/HardwareWallet.kt new file mode 100644 index 0000000000..c0d19e1a01 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/HardwareWallet.kt @@ -0,0 +1,98 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.AddressType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.serialization.Serializable + +/** A paired hardware wallet tracked as a watch-only balance. */ +@Stable +data class HwWallet( + val id: String, + val name: String, + val model: String?, + val transportType: TransportType, + val isConnected: Boolean, + val balanceSats: ULong, + val activities: ImmutableList, + val fundingBalanceSats: ULong = balanceSats, + val deviceIds: ImmutableSet = persistentSetOf(id), +) + +/** Serializable per-device balance snapshot carried by [BalanceState]. */ +@Immutable +@Serializable +data class HwWalletBalance( + val id: String, + val sats: ULong, +) + +/** A newly detected inbound transaction to a watched hardware wallet. */ +@Immutable +data class HwWalletReceivedTx( + val txid: String, + val sats: ULong, +) + +sealed interface HwFundingAccount { + val vendor: HwWalletVendor + val xpub: String + val addressType: HwFundingAddressType + val accountType: AccountType + val balanceSats: ULong + + data class Trezor( + override val xpub: String, + override val addressType: HwFundingAddressType, + override val balanceSats: ULong, + ) : HwFundingAccount { + override val vendor: HwWalletVendor = HwWalletVendor.TREZOR + override val accountType: AccountType + get() = addressType.accountType + } +} + +data class HwFundingTransaction( + val psbt: String, + val miningFeeSats: ULong, + val feeRate: Float, + val totalSpent: ULong, + val satsPerVByte: ULong, +) + +data class HwFundingBroadcastResult( + val txId: String, + val miningFeeSats: ULong, + val feeRate: ULong, + val totalSpent: ULong, +) + +enum class HwWalletVendor { + TREZOR, +} + +enum class HwFundingAddressType( + val addressType: AddressType, +) { + LEGACY(AddressType.P2PKH), + NESTED_SEGWIT(AddressType.P2SH), + NATIVE_SEGWIT(AddressType.P2WPKH), + TAPROOT(AddressType.P2TR); + + val settingsKey: String + get() = addressType.toSettingsString() + + val accountType: AccountType + get() = addressType.toAccountType() + + companion object { + val DEFAULT: HwFundingAddressType = entries.first { it.addressType == DEFAULT_ADDRESS_TYPE } + } +} + +fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/models/HwWallet.kt b/app/src/main/java/to/bitkit/models/HwWallet.kt deleted file mode 100644 index fc31bb78f2..0000000000 --- a/app/src/main/java/to/bitkit/models/HwWallet.kt +++ /dev/null @@ -1,39 +0,0 @@ -package to.bitkit.models - -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.synonym.bitkitcore.Activity -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.serialization.Serializable - -/** A paired hardware wallet tracked as a watch-only balance. */ -@Stable -data class HwWallet( - val id: String, - val name: String, - val model: String?, - val transportType: TransportType, - val isConnected: Boolean, - val balanceSats: ULong, - val activities: ImmutableList, - val deviceIds: ImmutableSet = persistentSetOf(id), -) - -/** Serializable per-device balance snapshot carried by [BalanceState]. */ -@Immutable -@Serializable -data class HwWalletBalance( - val id: String, - val sats: ULong, -) - -/** A newly detected inbound transaction to a watched hardware wallet. */ -@Immutable -data class HwWalletReceivedTx( - val txid: String, - val sats: ULong, -) - -fun HwWallet.toBalance() = HwWalletBalance(id = id, sats = balanceSats) diff --git a/app/src/main/java/to/bitkit/models/KnownDevice.kt b/app/src/main/java/to/bitkit/models/KnownDevice.kt new file mode 100644 index 0000000000..478afb9268 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/KnownDevice.kt @@ -0,0 +1,22 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class KnownDevice( + val id: String, + val name: String?, + val path: String, + val transportType: TransportType, + val label: String?, + val model: String?, + val lastConnectedAt: Long, + /** Account-level extended public keys per address type. */ + val xpubs: Map = emptyMap(), + /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ + val customLabel: String? = null, + /** Stable app-owned id for future wallet-scoped hardware activity metadata. */ + val walletId: String = "", +) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 17b632559f..7dfb8f4d10 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -213,6 +213,26 @@ class ActivityRepo @Inject constructor( notifyActivitiesChanged() } + suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result = withContext(bgDispatcher) { + runCatching { + val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching + val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp + .takeIf { activity.confirmed } + val updated = existing.copy( + confirmed = existing.confirmed || activity.confirmed, + confirmTimestamp = confirmTimestamp, + doesExist = if (activity.confirmed) true else existing.doesExist, + fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee, + updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp), + ) + if (updated == existing) return@runCatching + coreService.activity.update(existing.id, Activity.Onchain(updated)) + notifyActivitiesChanged() + }.onFailure { + Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG) + } + } + suspend fun handleOnchainTransactionReplaced(txid: String, conflicts: List) { coreService.activity.handleOnchainTransactionReplaced(txid, conflicts) notifyActivitiesChanged() diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 20df16bf90..f4992921c0 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -1,6 +1,9 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType @@ -36,17 +39,25 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.HwFundingAccount +import to.bitkit.models.HwFundingAddressType +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.safe import to.bitkit.models.toAccountType import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toTrezorCoinType import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.ceil import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -59,10 +70,12 @@ import kotlin.time.ExperimentalTime * Built on top of [TrezorRepo], which owns the device list, connect orchestration * and the underlying watcher transport. */ +@Suppress("TooManyFunctions") @OptIn(ExperimentalTime::class) @Singleton class HwWalletRepo @Inject constructor( private val trezorRepo: TrezorRepo, + private val activityRepo: ActivityRepo, private val hwWalletStore: HwWalletStore, private val settingsStore: SettingsStore, private val clock: Clock, @@ -132,6 +145,100 @@ class HwWalletRepo @Inject constructor( return trezorRepo.connect(deviceId) } + /** Reconnects a known paired device so its session is live for on-device signing. */ + suspend fun reconnect( + deviceId: String, + forceSession: Boolean = false, + ): Result = trezorRepo.connectKnownDevice(deviceId, forceSession = forceSession) + + suspend fun ensureConnected(deviceId: String): Result = trezorRepo.ensureConnected(deviceId) + + suspend fun getFundingAccount( + deviceId: String, + addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val devices = hwWalletStore.loadKnownDevices() + val target = requireNotNull(devices.find { it.id == deviceId }) { "Unknown hardware wallet '$deviceId'" } + val groupIds = devices.filter { it.walletKey == target.walletKey }.map { it.id }.toSet() + val xpub = requireNotNull(target.xpubs[addressType.settingsKey]) { + "Hardware wallet '$deviceId' has no '${addressType.settingsKey}' account" + } + val balanceSats = _watcherData.value + .values + .filter { + it.addressType == addressType.settingsKey && + it.deviceId in groupIds + } + .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } + HwFundingAccount.Trezor( + xpub = xpub, + addressType = addressType, + balanceSats = balanceSats, + ) + } + } + + /** Composes the exact on-chain funding payment before prompting for the Trezor signature. */ + suspend fun composeFundingTransaction( + deviceId: String, + address: String, + sats: ULong, + satsPerVByte: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val account = getFundingAccount(deviceId).getOrThrow() + val network = Env.network.toCoreNetwork() + val composed = trezorRepo.composeTransaction( + extendedKey = account.xpub, + outputs = listOf(ComposeOutput.Payment(address = address, amountSats = sats)), + feeRates = listOf(satsPerVByte.toFloat()), + network = network, + accountType = account.accountType, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ).getOrThrow() + val success = composed.filterIsInstance().firstOrNull() + ?: throw AppError( + composed.filterIsInstance().firstOrNull()?.error + ?: "Failed to compose hardware transfer" + ) + HwFundingTransaction( + psbt = success.psbt, + miningFeeSats = success.fee, + feeRate = success.feeRate, + totalSpent = success.totalSpent, + satsPerVByte = satsPerVByte, + ) + } + } + + /** Signs a composed funding payment on the Trezor and broadcasts it. */ + suspend fun signAndBroadcastFunding( + deviceId: String, + funding: HwFundingTransaction, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val signed = runSuspendCatching { + trezorRepo.signTxFromPsbt( + psbtBase64 = funding.psbt, + network = Env.network.toTrezorCoinType(), + ).getOrThrow() + } + if (signed.isFailure) { + trezorRepo.disconnectStaleSession(deviceId) + } + val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow() + HwFundingBroadcastResult( + txId = txId, + miningFeeSats = funding.miningFeeSats, + feeRate = ceil(funding.feeRate.toDouble()).toULong(), + totalSpent = funding.totalSpent, + ) + } + } + + suspend fun disconnectStaleSession(deviceId: String): Result = trezorRepo.disconnectStaleSession(deviceId) + /** * Persists the Bitkit-side funds label for a paired device. Applied to every entry sharing the * same wallet identity so the same device paired over both transports renames consistently. @@ -188,10 +295,13 @@ class HwWalletRepo @Inject constructor( .filter { it.xpubs.isNotEmpty() } .groupBy { it.walletKey } .map { (_, devices) -> - val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId } + val connectedDevice = devices.find { it.id == trezorState.connectedDeviceId() } val device = connectedDevice ?: devices.maxBy { it.lastConnectedAt } val ids = devices.map { it.id }.toSet() val deviceWatchers = watcherData.values.filter { it.deviceId in ids } + val fundingBalanceSats = deviceWatchers + .filter { it.addressType == HwFundingAddressType.DEFAULT.settingsKey } + .fold(0uL) { acc, watcher -> acc + watcher.balanceSats } HwWallet( id = device.id, name = device.displayName, @@ -202,6 +312,7 @@ class HwWalletRepo @Inject constructor( activities = deviceWatchers .toMergedActivities() .toImmutableList(), + fundingBalanceSats = fundingBalanceSats, deviceIds = ids.toImmutableSet(), ) } @@ -246,12 +357,16 @@ class HwWalletRepo @Inject constructor( .toImmutableList() val watcher = HwWatcherData( deviceId = watcherId.toDeviceId(), + addressType = watcherId.toAddressTypeKey(), balanceSats = event.balance.total, transactions = event.transactions.toImmutableList(), activities = activities, ) val updatedWatcherData = _watcherData.value + (watcherId to watcher) _watcherData.update { updatedWatcherData } + activities.filterIsInstance().forEach { + activityRepo.syncHardwareOnchainActivity(it.v1) + } emitReceivedTxs(previous, event, updatedWatcherData) } } @@ -426,6 +541,8 @@ class HwWalletRepo @Inject constructor( } private fun String.toDeviceId(): String = substringBefore(WATCHER_ID_SEPARATOR) + + private fun String.toAddressTypeKey(): String = substringAfter(WATCHER_ID_SEPARATOR) } private data class WatcherSettings( @@ -458,6 +575,7 @@ private val KnownDevice.displayName: String private data class HwWatcherData( val deviceId: String, + val addressType: String, val balanceSats: ULong, val transactions: ImmutableList, val activities: ImmutableList, diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index aff48fec82..584066edf3 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.BtOrderState2 +import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -16,6 +17,7 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType import to.bitkit.services.CoreService import to.bitkit.utils.BlockTimeHelpers @@ -94,6 +96,40 @@ class TransferRepo @Inject constructor( } } + @Suppress("LongParameterList") + suspend fun createPendingToSpendingActivity( + order: IBtOrder, + txId: String, + fee: ULong, + feeRate: ULong, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + val address = requireNotNull(order.payment?.onchain?.address?.takeIf { it.isNotEmpty() }) { + "Order '${order.id}' has no on-chain payment address" + } + coreService.activity.createSentOnchainActivityFromSendResult( + txid = txId, + address = address, + amount = order.feeSat, + fee = fee, + feeRate = feeRate, + isTransfer = true, + channelId = order.channel?.shortChannelId, + ) + }.onFailure { + Logger.error("Failed to create pending transfer activity for '$txId'", it, context = TAG) + } + } + + suspend fun findLspOrderIdByFundingTxId(fundingTxId: String): Result = withContext(bgDispatcher) { + runSuspendCatching { + transferDao.getByFundingTxId(fundingTxId)?.lspOrderId + }.onFailure { + Logger.warn("Failed to find transfer by funding txid '$fundingTxId'", it, context = TAG) + } + } + + @Suppress("CyclomaticComplexMethod") suspend fun syncTransferStates(): Result = withContext(bgDispatcher) { runCatching { val activeTransfers = transferDao.getActiveTransfers().first() @@ -108,6 +144,7 @@ class TransferRepo @Inject constructor( for (transfer in toSpending) { val channelId = resolveChannelIdForTransfer(transfer, channels) + channelId?.let { persistResolvedChannel(transfer, it) } val channel = channelId?.let { channels.find { c -> c.channelId == it } } if (channel != null && channel.isChannelReady) { markSettled(transfer.id) @@ -195,9 +232,17 @@ class TransferRepo @Inject constructor( Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG) } + private suspend fun persistResolvedChannel(transfer: TransferEntity, channelId: String) { + if (transfer.channelId == null) { + transferDao.update(transfer.copy(channelId = channelId)) + Logger.debug("Persisted channel '$channelId' for transfer '${transfer.id}'", context = TAG) + } + transfer.fundingTxId?.let { markActivityAsTransfer(it, channelId) } + } + private suspend fun markActivityAsTransfer(txid: String, channelId: String) { val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return - if (activity.isTransfer) return + if (activity.isTransfer && activity.channelId == channelId) return val updated = activity.copy(isTransfer = true, channelId = channelId) coreService.activity.update(activity.id, Activity.Onchain(updated)) Logger.debug("Marked activity ${activity.id} as transfer for channel $channelId", context = TAG) @@ -217,18 +262,24 @@ class TransferRepo @Inject constructor( Logger.debug("Marked activity ${activity.v1.id} as transfer for channel $channelId", context = TAG) } - /** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */ + /** Resolve channelId: direct transfer data first, then LSP order metadata, then funding tx fallback. */ suspend fun resolveChannelIdForTransfer( transfer: TransferEntity, channels: List, ): String? { - return transfer.lspOrderId - ?.let { orderId -> - val order = blocktankRepo.getOrder(orderId, refresh = false).getOrNull() - val fundingTxId = order?.channel?.fundingTx?.id ?: return null - return@let channels.find { it.fundingTxo?.txid == fundingTxId }?.channelId - } - ?: transfer.channelId + transfer.channelId?.let { return it } + + val orderFundingTxId = transfer.lspOrderId?.let { orderId -> + blocktankRepo.getOrder(orderId, refresh = false).getOrNull() + ?.channel + ?.fundingTx + ?.id + } + val fundingTxId = orderFundingTxId ?: transfer.fundingTxId + + return fundingTxId?.let { txid -> + channels.find { it.fundingTxo?.txid == txid }?.channelId + } } companion object { diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 745a92db4f..1bb77cf30b 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1,7 +1,6 @@ package to.bitkit.repositories import android.content.Context -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType @@ -40,6 +39,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -47,13 +47,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.toTransportType import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toCoreNetwork @@ -67,6 +69,7 @@ import to.bitkit.services.TrezorWalletMode import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -83,6 +86,7 @@ class TrezorRepo @Inject constructor( private val trezorTransport: TrezorTransport, private val trezorUiHandler: TrezorUiHandler, private val hwWalletStore: HwWalletStore, + private val settingsStore: SettingsStore, private val clock: Clock, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { @@ -176,8 +180,7 @@ class TrezorRepo @Inject constructor( .distinctBy { it.id } if (_state.value.connected != null) { - runCatching { trezorService.disconnect() } - .onFailure { Logger.warn("Failed to disconnect Trezor while resetting", it, context = TAG) } + runSuspendCatching { disconnect().getOrThrow() } } knownDevices.forEach { device -> @@ -216,7 +219,7 @@ class TrezorRepo @Inject constructor( passphrase: String = "", ): Result = withContext(ioDispatcher) { runCatching { - val deviceId = _state.value.connectedDeviceId + val deviceId = _state.value.connectedDeviceId() ?: throw AppError("No connected Trezor") TrezorDebugLog.log("WALLET_MODE", "Switching to $mode, resetting session for $deviceId") // Reset the session via disconnect/reconnect. disconnect() resets the @@ -389,7 +392,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getTransactionHistory( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = currentElectrumUrl(), network = network, scriptType = scriptType, ) @@ -408,7 +411,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAccountInfo( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = currentElectrumUrl(), network = network, scriptType = scriptType, ) @@ -426,7 +429,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAddressInfo( address = address, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = currentElectrumUrl(), network = network, ) }.onFailure { e -> @@ -446,11 +449,12 @@ class TrezorRepo @Inject constructor( ): Result> = withContext(ioDispatcher) { runCatching { awaitSetup() + ensureConnected() val fingerprint = trezorService.getDeviceFingerprint() val params = ComposeParams( wallet = WalletParams( extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = currentElectrumUrl(), fingerprint = fingerprint, network = network, accountType = accountType, @@ -483,13 +487,12 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, - network: BitkitCoreNetwork, ): Result = withContext(ioDispatcher) { runCatching { awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, - electrumUrl = electrumUrlForNetwork(network), + electrumUrl = currentElectrumUrl(), ) }.onFailure { Logger.error("Trezor broadcastRawTx failed", it, context = TAG) @@ -498,8 +501,13 @@ class TrezorRepo @Inject constructor( } suspend fun disconnect(): Result = withContext(ioDispatcher) { - TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") - val result = runCatching { trezorService.disconnect() } + val deviceId = _state.value.connectedDeviceId() + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=$deviceId") + val result = runCatching { + trezorService.disconnect() + deviceId?.let { disconnectTransportDevice(it) } + Unit + } // Mirror the core: trezorService.disconnect() resets the session // passphrase to the standard wallet, so reset the UI handler's wallet // mode too. This keeps the THP path, the legacy PassphraseRequest @@ -587,7 +595,7 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(isAutoReconnecting = true, error = null) } runCatching { awaitSetup(walletIndex) - val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice else null + val cachedFeatures = if (trezorService.isConnected()) _state.value.connectedDevice() else null if (cachedFeatures != null) { cachedFeatures } else { @@ -630,49 +638,74 @@ class TrezorRepo @Inject constructor( return false } - suspend fun connectKnownDevice(deviceId: String): Result = withContext(ioDispatcher) { + suspend fun connectKnownDevice( + deviceId: String, + forceSession: Boolean = false, + ): Result = withContext(ioDispatcher) { if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) } - runCatching { - _state.update { it.copy(isConnecting = true, error = null) } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") - TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") - TrezorDebugLog.log("RECONNECT", "Awaiting setup...") - awaitSetup() - TrezorDebugLog.log("RECONNECT", "Setup OK") - TrezorDebugLog.log("RECONNECT", "Scanning for devices...") - val scannedDevices = trezorService.scan() - TrezorDebugLog.log( - "RECONNECT", - "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}", - ) - // Honor the transport the user selected — connect to exactly the - // entry they tapped instead of overriding Bluetooth with USB. - val device = scannedDevices.find { it.id == deviceId } - ?: throw AppError("Device not found nearby — is it powered on?") - TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") - TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") - val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) - TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") - addOrUpdateKnownDevice(device, features) - _state.update { - it.copy(isConnecting = false, connected = ConnectedTrezorDevice(id = device.id, features = features)) + var startedConnecting = false + try { + runSuspendCatching { + startedConnecting = true + _state.update { it.copy(isConnecting = true, error = null) } + Logger.debug("Started known-device reconnect for '$deviceId'", context = TAG) + Logger.debug("Awaiting setup for reconnect", context = TAG) + awaitSetup() + Logger.debug("Completed setup for reconnect", context = TAG) + if (forceSession) { + Logger.debug("Closing stale session before reconnect for '$deviceId'", context = TAG) + disconnectStaleSession(deviceId) + } + Logger.debug("Scanning for reconnect devices", context = TAG) + val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + val knownDevice = knownDevices.find { it.matches(deviceId) } + val scannedDevices = trezorService.scan() + Logger.debug( + "Found '${scannedDevices.size}' reconnect devices '${scannedDevices.map { it.id }}'", + context = TAG, + ) + // Honor the transport the user selected — connect to exactly the + // entry they tapped instead of overriding Bluetooth with USB. + val device = scannedDevices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() + ?: throw AppError("Device not found nearby — is it powered on?") + Logger.debug("Found reconnect device '${device.id}'", context = TAG) + Logger.debug("Calling THP reconnect for '${device.id}'", context = TAG) + val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) + Logger.debug("Connected known device '${device.id}'", context = TAG) + addOrUpdateKnownDevice(device, features) + _state.update { it.copy(connected = ConnectedTrezorDevice(id = device.id, features = features)) } + Logger.info("Reconnected known device '${device.id}'", context = TAG) + features + }.onFailure { e -> + Logger.error("Connect known device failed", e, context = TAG) + _state.update { it.copy(error = e.message) } } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") - features - }.onFailure { e -> - TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") - Logger.error("Connect known device failed", e, context = TAG) - _state.update { it.copy(isConnecting = false, error = e.message) } + } finally { + if (startedConnecting) { + _state.update { it.copy(isConnecting = false) } + } + } + } + + suspend fun ensureConnected(deviceId: String): Result = withContext(ioDispatcher) { + val current = _state.value.connected + if (current?.id == deviceId && trezorService.isConnected()) { + return@withContext Result.success(current.features) } + connectKnownDevice(deviceId, forceSession = false) } suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") - val disconnectResult = if (_state.value.connectedDeviceId == deviceId) { - runCatching { trezorService.disconnect() }.also { + val disconnectResult = if (_state.value.connectedDeviceId() == deviceId) { + runCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) + }.also { // Clear any cached host passphrase so it can't be reused // against a different device on a later connect. trezorUiHandler.setWalletMode(TrezorWalletMode.STANDARD) @@ -762,7 +795,7 @@ class TrezorRepo @Inject constructor( private fun observeExternalDisconnects() { trezorTransport.externalDisconnect.onEach { path -> - val currentId = _state.value.connectedDeviceId ?: return@onEach + val currentId = _state.value.connectedDeviceId() ?: return@onEach val knownDevice = _state.value.knownDevices.find { it.path == path } if (knownDevice?.id == currentId || path.contains(currentId)) { Logger.warn("External disconnect detected for '$currentId'", context = TAG) @@ -842,6 +875,7 @@ class TrezorRepo @Inject constructor( val storedIds = stored.map { it.id }.toSet() val knownDevices = stored + _state.value.knownDevices.filter { it.id !in storedIds } val previous = knownDevices.find { it.id == deviceInfo.id } + val xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs() val known = KnownDevice( id = deviceInfo.id, name = deviceInfo.name, @@ -850,8 +884,9 @@ class TrezorRepo @Inject constructor( label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, lastConnectedAt = clock.nowMs(), - xpubs = previous?.xpubs.orEmpty() + fetchAccountXpubs(), + xpubs = xpubs, customLabel = previous?.customLabel, + walletId = knownDevices.findHardwareWalletId(deviceInfo.id, xpubs), ) val updated = knownDevices.filter { it.id != known.id } + known saveKnownDevices(updated) @@ -880,7 +915,12 @@ class TrezorRepo @Inject constructor( } private suspend fun loadKnownDevices(): List = runCatching { - hwWalletStore.loadKnownDevices() + val devices = hwWalletStore.loadKnownDevices() + val migrated = devices.withHardwareWalletIds() + if (migrated != devices) { + hwWalletStore.saveKnownDevices(migrated) + } + migrated }.onFailure { Logger.error("Failed to load known devices", it, context = TAG) }.getOrDefault(emptyList()) @@ -893,14 +933,19 @@ class TrezorRepo @Inject constructor( private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = Env.electrumUrlForNetwork(network) + private suspend fun currentElectrumUrl(): String = settingsStore.data.first().electrumServer + private suspend fun ensureConnected() { if (trezorService.isConnected()) return - val deviceId = _state.value.connectedDeviceId + val deviceId = _state.value.connectedDeviceId() ?: _state.value.knownDevices.firstOrNull()?.id ?: throw AppError("No device to reconnect") awaitSetup() + val knownDevices = (_state.value.knownDevices + loadKnownDevices()).distinctBy { it.id } + val knownDevice = knownDevices.find { it.matches(deviceId) } val devices = trezorService.scan() val device = devices.find { it.id == deviceId } + ?: knownDevice?.takeIf { it.transportType == TransportType.BLUETOOTH }?.toDeviceInfo() ?: throw AppError("Device not found during reconnect") val features = connectWithThpRetry(device.id, trezorUiHandler.currentSelection()) _state.update { it.copy(connected = ConnectedTrezorDevice(id = deviceId, features = features)) } @@ -948,15 +993,38 @@ class TrezorRepo @Inject constructor( throw e } TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") - Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG) + Logger.warn("Failed to connect to '$deviceId', retrying", e, context = TAG) logCredentialFileState(deviceId, "BEFORE 2nd attempt") - val result = connectDevice(deviceId, selection, requestUsbPermission) + val result = runSuspendCatching { + connectDevice(deviceId, selection, requestUsbPermission) + }.onFailure { + disconnectStaleSession(deviceId) + }.getOrThrow() logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") TrezorDebugLog.log("THPRetry", "Second attempt succeeded") result } } + suspend fun disconnectStaleSession(deviceId: String): Result = withContext(ioDispatcher) { + val result = runSuspendCatching { + trezorService.disconnect() + disconnectTransportDevice(deviceId) + } + .onFailure { + Logger.warn("Failed to disconnect stale Trezor session for '$deviceId'", it, context = TAG) + } + _state.update { it.copy(connected = null) } + result + } + + private suspend fun disconnectTransportDevice(deviceId: String) { + val knownDevice = (_state.value.knownDevices + loadKnownDevices()) + .distinctBy { it.id } + .find { it.matches(deviceId) } + trezorTransport.disconnectDevice(knownDevice?.path ?: deviceId) + } + private suspend fun connectDevice( deviceId: String, selection: WalletSelection, @@ -999,11 +1067,9 @@ data class TrezorState( val lastPublicKey: TrezorPublicKeyResponse? = null, val error: String? = null, ) { - val connectedDevice: TrezorFeatures? - get() = connected?.features + fun connectedDevice(): TrezorFeatures? = connected?.features - val connectedDeviceId: String? - get() = connected?.id + fun connectedDeviceId(): String? = connected?.id } @Stable @@ -1012,29 +1078,45 @@ data class ConnectedTrezorDevice( val features: TrezorFeatures, ) -@Serializable -@Immutable -data class KnownDevice( - val id: String, - val name: String?, - val path: String, - val transportType: TransportType, - val label: String?, - val model: String?, - val lastConnectedAt: Long, - /** Account-level extended public keys per address type (key = [AddressType.toSettingsString]). */ - val xpubs: Map = emptyMap(), - /** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */ - val customLabel: String? = null, -) - private fun KnownDevice.matches(deviceId: String) = id == deviceId || path == deviceId -private fun TrezorTransportType.toTransportType(): TransportType = when (this) { - TrezorTransportType.BLUETOOTH -> TransportType.BLUETOOTH - TrezorTransportType.USB -> TransportType.USB +private val KnownDevice.walletKey: String + get() = walletKey(xpubs, id) + +private fun walletKey(xpubs: Map, fallback: String): String = + xpubs.values.sorted().joinToString().ifEmpty { fallback } + +private fun List.findHardwareWalletId(deviceId: String, xpubs: Map): String { + val walletKey = walletKey(xpubs, deviceId) + return firstOrNull { it.id == deviceId }?.walletId?.takeIf { it.isNotBlank() } + ?: firstOrNull { it.walletKey == walletKey }?.walletId?.takeIf { it.isNotBlank() } + ?: newHardwareWalletId() } +private fun List.withHardwareWalletIds(): List { + val existingByWallet = filter { it.walletId.isNotBlank() } + .associate { it.walletKey to it.walletId } + val generatedByWallet = mutableMapOf() + + return map { + val walletId = existingByWallet[it.walletKey] + ?: generatedByWallet.getOrPut(it.walletKey) { newHardwareWalletId() } + if (it.walletId == walletId) it else it.copy(walletId = walletId) + } +} + +private fun newHardwareWalletId(): String = UUID.randomUUID().toString() + +private fun KnownDevice.toDeviceInfo() = TrezorDeviceInfo( + id = id, + transportType = transportType.toCoreTransportType(), + name = name, + path = path, + label = label, + model = model, + isBootloader = false, +) + private fun TransportType.toCoreTransportType(): TrezorTransportType = when (this) { TransportType.BLUETOOTH -> TrezorTransportType.BLUETOOTH TransportType.USB -> TrezorTransportType.USB diff --git a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt index 27d246ae08..023045080d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt @@ -24,6 +24,8 @@ import javax.inject.Singleton class TrezorBridgeTransport( private val baseUrl: String, private val enabled: Boolean, + private val readTimeoutMs: Int = READ_TIMEOUT_MS, + private val callReadTimeoutMs: Int = CALL_READ_TIMEOUT_MS, ) { @Inject constructor() : this( @@ -40,6 +42,13 @@ class TrezorBridgeTransport( private const val HEADER_SIZE = 6 private const val CONNECT_TIMEOUT_MS = 5_000 private const val READ_TIMEOUT_MS = 30_000 + private const val CALL_READ_TIMEOUT_MS = 120_000 + + /** + * Trezor protobuf MessageType_SignTx. This is the only call that waits + * for on-device signing. + */ + private const val SIGN_TX_MESSAGE_TYPE = 15 } private val json = Json { ignoreUnknownKeys = true } @@ -139,7 +148,8 @@ class TrezorBridgeTransport( return runCatching { val request = encodeFrame(messageType, data) - val response = post("/call/${encode(session)}", request) + val timeoutMs = if (messageType == SIGN_TX_MESSAGE_TYPE.toUShort()) callReadTimeoutMs else readTimeoutMs + val response = post("/call/${encode(session)}", request, readTimeoutMs = timeoutMs) decodeFrame(response) }.getOrElse { Logger.warn("Failed to call Trezor Bridge message for '$path'", it, context = TAG) @@ -182,11 +192,11 @@ class TrezorBridgeTransport( ) } - private fun post(path: String, body: String? = null): String { + private fun post(path: String, body: String? = null, readTimeoutMs: Int = this.readTimeoutMs): String { val connection = URL("$bridgeUrl$path").openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.connectTimeout = CONNECT_TIMEOUT_MS - connection.readTimeout = READ_TIMEOUT_MS + connection.readTimeout = readTimeoutMs connection.doInput = true if (body != null) { diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 8aacd8a72c..cba4965b59 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -323,6 +323,17 @@ class TrezorTransport @Inject constructor( } } + fun disconnectDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("DISCONNECT", "disconnectDevice: $path") + return if (bridgeTransport.isBridgeDevice(path)) { + bridgeTransport.closeDevice(path) + } else if (isBleDevice(path)) { + disconnectBleDevice(path) + } else { + closeUsbDevice(path) + } + } + override fun readChunk(path: String): TrezorTransportReadResult { return if (bridgeTransport.isBridgeDevice(path)) { bridgeTransport.readChunk(path) @@ -889,8 +900,19 @@ class TrezorTransport @Inject constructor( return null } + @Suppress("ReturnCount") @SuppressLint("MissingPermission") private fun openBleDevice(path: String): TrezorTransportWriteResult { + bleConnections[path]?.takeIf { it.isConnected && it.writeCharacteristic != null }?.let { + val staleCount = it.readQueue.size + if (staleCount > 0) { + it.readQueue.clear() + TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") + } + Logger.info("Reused open BLE device '$path'", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + val address = path.removePrefix("ble:") // Prefer a handle from a recent scan, but fall back to resolving the // address directly so we can reconnect to a known device without a @@ -899,7 +921,7 @@ class TrezorTransport @Inject constructor( ?: runCatching { bluetoothAdapter?.getRemoteDevice(address) }.getOrNull() ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") - closeBleDevice(path) + bleConnections[path]?.takeIf { !it.isConnected }?.let { disconnectBleDevice(path) } val bondError = waitForBonding(device, address) if (bondError != null) return bondError @@ -917,13 +939,13 @@ class TrezorTransport @Inject constructor( bleConnections[path] = connection if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - closeBleDevice(path) + disconnectBleDevice(path) return TrezorTransportWriteResult(success = false, error = "Connection timeout") } val updatedConnection = bleConnections[path] if (updatedConnection == null || !updatedConnection.isConnected) { - closeBleDevice(path) + disconnectBleDevice(path) return TrezorTransportWriteResult(success = false, error = "Failed to connect") } @@ -948,6 +970,19 @@ class TrezorTransport @Inject constructor( val connection = bleConnections[path] ?: return TrezorTransportWriteResult(success = true, error = "") + connection.readQueue.clear() + connection.writeLatch?.countDown() + connection.connectionLatch?.countDown() + Logger.info("Closed BLE device session '$path'", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + @Suppress("TooGenericExceptionCaught") + @SuppressLint("MissingPermission") + private fun disconnectBleDevice(path: String): TrezorTransportWriteResult { + val connection = bleConnections[path] + ?: return TrezorTransportWriteResult(success = true, error = "") + userInitiatedCloseSet.add(path) return try { val disconnectLatch = CountDownLatch(1) @@ -1316,6 +1351,6 @@ class TrezorTransport @Inject constructor( fun closeAllConnections() { usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } - bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } + bleConnections.keys.toList().forEach { path -> disconnectBleDevice(path) } } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 515e011b66..d98a6e7734 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -128,6 +128,9 @@ import to.bitkit.ui.screens.transfer.external.ExternalConnectionScreen import to.bitkit.ui.screens.transfer.external.ExternalNodeViewModel import to.bitkit.ui.screens.transfer.external.ExternalSuccessScreen import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingAmountHwScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignScreen +import to.bitkit.ui.screens.transfer.hardware.SpendingHwSignedScreen import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.wallets.HardwareWalletScreen import to.bitkit.ui.screens.wallets.HomeScreen @@ -179,7 +182,6 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute @@ -753,6 +755,16 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + SpendingIntroScreen( + onContinueClick = { + navController.navigateTo(Routes.SpendingAmountHw(deviceId)) + settingsViewModel.setHasSeenSpendingIntro(true) + }, + onBackClick = { navController.popBackStack() }, + ) + } composableWithDefaultTransitions { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountScreen( @@ -770,6 +782,36 @@ private fun RootNavHost( }, ) } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() + SpendingAmountHwScreen( + deviceId = deviceId, + viewModel = transferViewModel, + isOffline = connectivityState != ConnectivityState.CONNECTED, + onBackClick = { navController.popBackStack() }, + onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(deviceId)) }, + ) + } + composableWithDefaultTransitions { entry -> + val deviceId = entry.toRoute().deviceId + SpendingHwSignScreen( + deviceId = deviceId, + viewModel = transferViewModel, + onBackClick = { navController.popBackStack() }, + onCloseClick = { navController.navigateToHome() }, + onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, + onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, + onSigned = { navController.navigateTo(Routes.SpendingHwSigned) }, + ) + } + composableWithDefaultTransitions { + SpendingHwSignedScreen( + viewModel = transferViewModel, + onContinue = { navController.navigateTo(Routes.SettingUp) }, + onCloseClick = { navController.navigateToHome() }, + ) + } composableWithDefaultTransitions { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingConfirmScreen( @@ -786,7 +828,8 @@ private fun RootNavHost( SpendingAdvancedScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - onOrderCreated = { navController.popBackStack(inclusive = false) }, + // Pops back to whoever opened Advanced: SpendingConfirm or SpendingHwSign. + onOrderCreated = { navController.popBackStack() }, ) } composableWithDefaultTransitions { @@ -809,11 +852,7 @@ private fun RootNavHost( FundingScreen( onTransfer = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onFund = { scope.launch { @@ -958,11 +997,7 @@ private fun NavGraphBuilder.home( onActivityItemClick = { navController.navigateToActivityItem(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSpendingClick = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onBackClick = { navController.popBackStack() }, forceCloseRemainingDuration = forceCloseRemainingDuration, @@ -988,27 +1023,19 @@ private fun NavGraphBuilder.home( } }, onTransferFromSavingsClick = { - if (!hasSeenSpendingIntro) { - navController.navigateToTransferSpendingIntro() - } else { - navController.navigateToTransferSpendingAmount() - } + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) }, onBackClick = { navController.popBackStack() }, ) } composableWithDefaultTransitions { - val scope = rememberCoroutineScope() + val deviceId = it.toRoute().deviceId + val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( - deviceId = it.toRoute().deviceId, + deviceId = deviceId, onActivityItemClick = { id -> navController.navigateToActivityItem(id) }, - onTransferToSpendingClick = { - scope.launch { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Transfer to spending not yet implemented.", - ) - } + onTransferToSpendingClick = { selectedDeviceId -> + navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId) }, onBackClick = { navController.popBackStack() }, ) @@ -1797,9 +1824,26 @@ fun NavController.navigateToTransferSavingsIntro() = navigateTo(Routes.SavingsIn fun NavController.navigateToTransferSavingsAvailability() = navigateTo(Routes.SavingsAvailability) -fun NavController.navigateToTransferSpendingIntro() = navigateTo(Routes.SpendingIntro) +fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) = + navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro)) + +fun NavController.navigateToTransferSpendingStart( + hasSeenSpendingIntro: Boolean, + deviceId: String, +) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) -fun NavController.navigateToTransferSpendingAmount() = navigateTo(Routes.SpendingAmount) +internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { + hasSeenSpendingIntro -> Routes.SpendingAmount + else -> Routes.SpendingIntro +} + +internal fun transferSpendingStartRoute( + hasSeenSpendingIntro: Boolean, + deviceId: String, +): Routes = when { + hasSeenSpendingIntro -> Routes.SpendingAmountHw(deviceId) + else -> Routes.SpendingIntroHw(deviceId) +} fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro) @@ -1961,9 +2005,21 @@ sealed interface Routes { @Serializable data object SpendingIntro : Routes + @Serializable + data class SpendingIntroHw(val deviceId: String) : Routes + @Serializable data object SpendingAmount : Routes + @Serializable + data class SpendingAmountHw(val deviceId: String) : Routes + + @Serializable + data class SpendingHwSign(val deviceId: String) : Routes + + @Serializable + data object SpendingHwSigned : Routes + @Serializable data object SpendingConfirm : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt index e4c2bec4e6..7f0c520bb6 100644 --- a/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt +++ b/app/src/main/java/to/bitkit/ui/components/HwWalletComponents.kt @@ -1,6 +1,8 @@ package to.bitkit.ui.components +import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.offset @@ -11,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -26,6 +29,12 @@ import to.bitkit.ui.theme.Colors /** Illustration width as a fraction of the sheet width — the 256-wide Visual in the 375-wide Figma frame. */ internal const val HW_ILLUSTRATION_SIZE_RATIO = 256f / 375f +/** Figma top ratio for the signed check visual within the content area below navigation. */ +internal const val SIGNED_VISUAL_TOP_RATIO = (481f - 92f) / (812f - 92f - 34f) + +/** Figma top ratio for the Trezor visual within the content area below navigation. */ +internal const val SIGN_VISUAL_TOP_RATIO = (488f - 92f) / (812f - 92f - 34f) + /** Trezor illustration left bleed past the frame, as a fraction of the sheet width (Figma device frames). */ private const val HW_DEVICE_TREZOR_BLEED_RATIO = 84f / 375f @@ -83,6 +92,29 @@ fun HwWalletConnectionIcon( ) } +@Composable +internal fun BoxScope.HardwareTransferIllustration( + modifier: Modifier = Modifier, + @DrawableRes drawableRes: Int, + topRatio: Float, +) { + BoxWithConstraints(modifier = Modifier.matchParentSize()) { + val visualSize = maxWidth * HW_ILLUSTRATION_SIZE_RATIO + val topOffset = maxHeight * topRatio + + Image( + painter = painterResource(id = drawableRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = topOffset) + .size(visualSize) + .then(modifier) + ) + } +} + @Composable private fun BoxWithConstraintsScope.TrezorImage( imageSize: Dp, diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt index b158ff18fd..aab4a2f7ec 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPadTextField.kt @@ -81,6 +81,7 @@ private fun MoneyAmount( } Row( verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { if (!isSymbolSuffix) { Display( @@ -101,7 +102,8 @@ private fun MoneyAmount( append(placeholder) } } - } + }, + modifier = if (isSymbolSuffix) Modifier else Modifier.weight(1f) ) if (isSymbolSuffix) { Display( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 06a48a3415..ad1f623ccb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -93,6 +93,7 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() + TransferEffect.OnHwTxSigned -> Unit is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) @@ -175,7 +176,8 @@ private fun Content( Display( text = stringResource(R.string.lightning__spending_advanced__title) - .withAccent(accentColor = Colors.Purple) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) FillHeight() diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 36ea358a05..ec1b4349d4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -85,6 +85,7 @@ fun SpendingAmountScreen( TransferEffect.OnOrderCreated -> onOrderCreated() is TransferEffect.ToastError -> toast(effect.title, effect.description) is TransferEffect.ToastException -> toastException(effect.e) + else -> Unit } } } @@ -196,7 +197,8 @@ private fun SpendingAmountNodeRunning( Display( text = stringResource(R.string.lightning__spending_amount__title) - .withAccent(accentColor = Colors.Purple) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() ) FillHeight() diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt new file mode 100644 index 0000000000..257519fe94 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt @@ -0,0 +1,66 @@ +package to.bitkit.ui.screens.transfer + +import com.synonym.bitkitcore.BtBolt11InvoiceState +import com.synonym.bitkitcore.BtOrderState +import com.synonym.bitkitcore.BtOrderState2 +import com.synonym.bitkitcore.BtPaymentState +import com.synonym.bitkitcore.BtPaymentState2 +import com.synonym.bitkitcore.IBtBolt11Invoice +import com.synonym.bitkitcore.IBtOnchainTransactions +import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.IBtPayment +import com.synonym.bitkitcore.ILspNode + +internal fun previewBtOrder( + networkFeeSat: ULong = 2_483UL, + serviceFeeSat: ULong = 1_520UL, + clientBalanceSat: ULong = 967_724UL, + feeSat: ULong = 971_727UL, +): IBtOrder = IBtOrder( + id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", + state = BtOrderState.CREATED, + state2 = BtOrderState2.CREATED, + feeSat = feeSat, + networkFeeSat = networkFeeSat, + serviceFeeSat = serviceFeeSat, + lspBalanceSat = 2_000_000UL, + clientBalanceSat = clientBalanceSat, + zeroConf = false, + zeroReserve = true, + clientNodeId = null, + channelExpiryWeeks = 8u, + channelExpiresAt = "2025-09-22T08:29:03Z", + orderExpiresAt = "2025-07-29T08:29:03Z", + channel = null, + lspNode = ILspNode( + alias = "Bitkit LSP", + pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", + connectionStrings = listOf("mock@127.0.0.1:9735"), + readonly = null, + ), + lnurl = null, + payment = IBtPayment( + state = BtPaymentState.CREATED, + state2 = BtPaymentState2.CREATED, + paidSat = 0UL, + bolt11Invoice = IBtBolt11Invoice( + request = "lnmock", + state = BtBolt11InvoiceState.PENDING, + expiresAt = "2025-07-28T12:00:00Z", + updatedAt = "2025-07-28T08:30:00Z", + ), + onchain = IBtOnchainTransactions( + address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + confirmedSat = 0UL, + requiredConfirmations = 1u, + transactions = emptyList(), + ), + isManuallyPaid = null, + manualRefunds = null, + ), + couponCode = null, + source = null, + discount = null, + updatedAt = "2025-07-28T08:29:03Z", + createdAt = "2025-07-28T08:29:03Z", +) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt new file mode 100644 index 0000000000..5d04316739 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -0,0 +1,292 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.models.formatToModernDisplay +import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.ConnectionIssuesView +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadActionButton +import to.bitkit.ui.components.NumberPadTextField +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SyncNodeView +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.UnitButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputEffect +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.TransferEffect +import to.bitkit.viewmodels.TransferToSpendingUiState +import to.bitkit.viewmodels.TransferViewModel +import to.bitkit.viewmodels.previewAmountInputViewModel + +@Suppress("ViewModelForwarding") +@Composable +fun SpendingAmountHwScreen( + deviceId: String, + viewModel: TransferViewModel, + isOffline: Boolean, + onBackClick: () -> Unit = {}, + onOrderCreated: () -> Unit = {}, + currencies: CurrencyState = LocalCurrencies.current, + amountInputViewModel: AmountInputViewModel = hiltViewModel(), +) { + val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() + val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) + val currentCurrencies by rememberUpdatedState(currencies) + + LaunchedEffect(deviceId, isOffline) { + viewModel.updateHwLimits(deviceId) + } + + LaunchedEffect(Unit) { + viewModel.transferEffects.collect { effect -> + when (effect) { + TransferEffect.OnOrderCreated -> onOrderCreated() + is TransferEffect.ToastError -> ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = effect.title, + description = effect.description, + ) + is TransferEffect.ToastException -> ToastEventBus.send(effect.e) + else -> Unit + } + } + } + + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> { + amountInputViewModel.setSats(currentMaxAllowedToSend, currentCurrencies) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), + ) + } + } + } + } + + Box { + Content( + isNodeRunning = isNodeRunning, + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onBackClick = onBackClick, + onClickQuarter = { + amountInputViewModel.setSats(uiState.quarterAmount, currencies) + }, + onClickMaxAmount = { + amountInputViewModel.setSats(uiState.maxAllowedToSend, currencies) + }, + onConfirmAmount = { viewModel.onConfirmAmount(amountUiState.sats) }, + ) + AnimatedVisibility( + visible = isOffline, + enter = fadeIn(), + exit = fadeOut(), + ) { + ConnectionIssuesView( + titleText = stringResource(R.string.lightning__transfer__nav_title), + modifier = Modifier.statusBarsPadding() + ) + } + } +} + +@Suppress("ViewModelForwarding") +@Composable +private fun Content( + isNodeRunning: Boolean = true, + uiState: TransferToSpendingUiState, + amountInputViewModel: AmountInputViewModel, + onBackClick: () -> Unit = {}, + onClickQuarter: () -> Unit = {}, + onClickMaxAmount: () -> Unit = {}, + onConfirmAmount: () -> Unit = {}, + currencies: CurrencyState = LocalCurrencies.current, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + + if (isNodeRunning) { + NodeRunning( + uiState = uiState, + amountInputViewModel = amountInputViewModel, + currencies = currencies, + onClickQuarter = onClickQuarter, + onClickMaxAmount = onClickMaxAmount, + onConfirmAmount = onConfirmAmount, + ) + } else { + SyncNodeView( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + } +} + +@Suppress("ViewModelForwarding") +@Composable +private fun NodeRunning( + uiState: TransferToSpendingUiState, + amountInputViewModel: AmountInputViewModel, + currencies: CurrencyState, + onClickQuarter: () -> Unit = {}, + onClickMaxAmount: () -> Unit = {}, + onConfirmAmount: () -> Unit = {}, +) { + LaunchedEffect(uiState.maxAllowedToSend) { + amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferAmount") + ) { + val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + + VerticalSpacer(minHeight = 16.dp, maxHeight = 32.dp) + + Display( + text = stringResource(R.string.lightning__spending_amount__title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() + ) + + FillHeight() + + NumberPadTextField( + viewModel = amountInputViewModel, + currencies = currencies, + showSecondaryField = false, + modifier = Modifier + .fillMaxWidth() + .testTag("HardwareTransferAmountNumberField") + ) + + FillHeight() + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + .testTag("HardwareTransferAmountNumberPad") + ) { + Column { + Text13Up( + text = stringResource(R.string.wallet__send_available), + color = Colors.White64, + modifier = Modifier.testTag("HardwareTransferAmountAvailable") + ) + VerticalSpacer(8.dp) + MoneySSB(sats = uiState.balanceAfterFee, modifier = Modifier.testTag("HardwareTransferAmountUnit")) + } + FillWidth() + UnitButton( + color = Colors.Purple, + onClick = { amountInputViewModel.switchUnit(currencies) }, + modifier = Modifier.testTag("HardwareTransferAmountUnitButton") + ) + NumberPadActionButton( + text = stringResource(R.string.lightning__spending_amount__quarter), + color = Colors.Purple, + onClick = onClickQuarter, + modifier = Modifier.testTag("HardwareTransferAmountQuarter") + ) + NumberPadActionButton( + text = stringResource(R.string.common__max), + color = Colors.Purple, + onClick = onClickMaxAmount, + modifier = Modifier.testTag("HardwareTransferAmountMax") + ) + } + + HorizontalDivider() + VerticalSpacer(16.dp) + + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + enabled = !uiState.isLoading, + ) + + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = onConfirmAmount, + enabled = !uiState.isLoading && amountUiState.sats <= uiState.maxAllowedToSend, + isLoading = uiState.isLoading, + modifier = Modifier.testTag("HardwareTransferAmountContinue") + ) + + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Preview(showSystemUi = true, device = "id:pixel_9_pro_xl", name = "Large") +@Preview(showSystemUi = true, device = NEXUS_5, name = "Small") +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = TransferToSpendingUiState(maxAllowedToSend = 158_234, balanceAfterFee = 158_234), + amountInputViewModel = previewAmountInputViewModel(), + currencies = CurrencyState(), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt new file mode 100644 index 0000000000..5de144a582 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -0,0 +1,221 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.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 com.synonym.bitkitcore.IBtOrder +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FeeInfo +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.HardwareTransferIllustration +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SIGN_VISUAL_TOP_RATIO +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferEffect +import to.bitkit.viewmodels.TransferViewModel + +@Composable +fun SpendingHwSignScreen( + deviceId: String, + viewModel: TransferViewModel, + onBackClick: () -> Unit, + onCloseClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onAdvancedClick: () -> Unit, + onSigned: () -> Unit, +) { + val state by viewModel.spendingUiState.collectAsStateWithLifecycle() + + val order = state.order ?: run { + onCloseClick() + return + } + + LaunchedEffect(Unit) { + viewModel.transferEffects.collect { effect -> + when (effect) { + TransferEffect.OnHwTxSigned -> onSigned() + else -> Unit + } + } + } + + Content( + order = order, + isAdvanced = state.isAdvanced, + isSigning = state.isSigning, + onBackClick = onBackClick, + onLearnMoreClick = onLearnMoreClick, + onAdvancedClick = onAdvancedClick, + onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, + onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, deviceId) }, + ) +} + +@Composable +private fun Content( + order: IBtOrder, + isAdvanced: Boolean = false, + isSigning: Boolean = false, + onBackClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {}, + onAdvancedClick: () -> Unit = {}, + onUseDefaultLspBalanceClick: () -> Unit = {}, + onOpenConnect: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + Box(modifier = Modifier.fillMaxSize()) { + HardwareTransferIllustration( + drawableRes = R.drawable.trezor, + topRatio = SIGN_VISUAL_TOP_RATIO, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferSign") + ) { + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.lightning__transfer_hw__sign_title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() + ) + VerticalSpacer(16.dp) + + SpendingHwFeeGrid(order = order) + + VerticalSpacer(24.dp) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PrimaryButton( + text = stringResource(R.string.common__learn_more), + size = ButtonSize.Small, + fullWidth = false, + onClick = onLearnMoreClick, + modifier = Modifier.testTag("HardwareTransferSignLearnMore") + ) + PrimaryButton( + text = stringResource( + if (isAdvanced) R.string.lightning__spending_confirm__default else R.string.common__advanced + ), + size = ButtonSize.Small, + fullWidth = false, + onClick = { if (isAdvanced) onUseDefaultLspBalanceClick() else onAdvancedClick() }, + modifier = Modifier.testTag( + if (isAdvanced) "HardwareTransferSignDefault" else "HardwareTransferSignAdvanced" + ) + ) + } + + FillHeight() + + PrimaryButton( + text = stringResource(R.string.lightning__transfer_hw__open_connect), + onClick = onOpenConnect, + enabled = !isSigning, + isLoading = isSigning, + modifier = Modifier.testTag("HardwareTransferOpenTrezorConnect") + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Composable +internal fun SpendingHwFeeGrid( + order: IBtOrder, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__network_fee), + amount = order.networkFeeSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__lsp_fee), + amount = order.serviceFeeSat.toLong(), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__amount), + amount = order.clientBalanceSat.toLong(), + ) + FeeInfo( + label = stringResource(R.string.lightning__spending_confirm__total), + amount = order.feeSat.toLong(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + order = previewBtOrder(), + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewAdvanced() { + AppThemeSurface { + Content( + order = previewBtOrder(), + isAdvanced = true, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewSigning() { + AppThemeSurface { + Content( + order = previewBtOrder(), + isSigning = true, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt new file mode 100644 index 0000000000..707fc91544 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt @@ -0,0 +1,102 @@ +package to.bitkit.ui.screens.transfer.hardware + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 com.synonym.bitkitcore.IBtOrder +import kotlinx.coroutines.delay +import to.bitkit.R +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.HardwareTransferIllustration +import to.bitkit.ui.components.SIGNED_VISUAL_TOP_RATIO +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferViewModel + +/** Figma handoff delay before forwarding from signed confirmation. */ +private const val SIGNED_AUTO_NAV_DELAY_MS = 1_000L + +@Composable +fun SpendingHwSignedScreen( + viewModel: TransferViewModel, + onContinue: () -> Unit, + onCloseClick: () -> Unit, +) { + val state by viewModel.spendingUiState.collectAsStateWithLifecycle() + + val order = state.order ?: run { + onCloseClick() + return + } + + LaunchedEffect(Unit) { + delay(SIGNED_AUTO_NAV_DELAY_MS) + onContinue() + } + + Content(order = order, onBackClick = onCloseClick) +} + +@Composable +private fun Content( + order: IBtOrder, + onBackClick: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.lightning__transfer__nav_title), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + Box(modifier = Modifier.fillMaxSize()) { + HardwareTransferIllustration( + drawableRes = R.drawable.check, + topRatio = SIGNED_VISUAL_TOP_RATIO, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .testTag("HardwareTransferSigned") + ) { + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.lightning__transfer_hw__signed_title) + .withAccent(accentColor = Colors.Purple), + modifier = Modifier.fillMaxWidth() + ) + VerticalSpacer(16.dp) + + SpendingHwFeeGrid(order = order) + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + order = previewBtOrder(), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index 37958ed0fb..0abfa7ee54 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType -import to.bitkit.repositories.KnownDevice import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index a0b8d3e86e..6a0bdac66b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -19,9 +19,9 @@ import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.repositories.ConnectedTrezorDevice -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import com.synonym.bitkitcore.Network as BitkitCoreNetwork diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index e461a0842f..4636ee60f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -48,8 +48,8 @@ import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.CoinSelection import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.models.KnownDevice import to.bitkit.repositories.ConnectedTrezorDevice -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorWalletMode @@ -251,7 +251,7 @@ private fun Content( if (trezorState.connected != null) { WalletModeRow( walletMode = walletMode, - passphraseEntryCapable = trezorState.connectedDevice?.passphraseEntryCapable == true, + passphraseEntryCapable = trezorState.connectedDevice()?.passphraseEntryCapable == true, onSetWalletMode = onSetWalletMode, ) } @@ -270,7 +270,7 @@ private fun Content( ) VerticalSpacer(8.dp) trezorState.knownDevices.forEach { device -> - val isConnected = trezorState.connectedDeviceId == device.id + val isConnected = trezorState.connectedDeviceId() == device.id KnownDeviceCard( device = device, isConnected = isConnected, @@ -315,11 +315,11 @@ private fun Content( // Connected Device Info AnimatedVisibility( - visible = trezorState.connectedDevice != null, + visible = trezorState.connectedDevice() != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - trezorState.connectedDevice?.let { features -> + trezorState.connectedDevice()?.let { features -> Column { VerticalSpacer(32.dp) Caption13Up( @@ -406,7 +406,7 @@ private fun Content( VerticalSpacer(32.dp) BalanceLookupSection( uiState = uiState, - isDeviceConnected = trezorState.connectedDevice != null, + isDeviceConnected = trezorState.connectedDevice() != null, onInputChange = onLookupInputChange, onAccountTypeChange = onLookupAccountTypeChange, onLookup = onLookup, @@ -668,7 +668,7 @@ private fun StatusRow(trezorState: TrezorState) { Caption("Connecting...", color = Colors.White64) } - trezorState.connectedDevice != null -> { + trezorState.connectedDevice() != null -> { StatusBadge(text = "Connected", color = Colors.Green) } @@ -704,7 +704,7 @@ private fun ActionButtonsRow( modifier = Modifier.fillMaxWidth() ) { if (trezorState.isAutoReconnecting) return@Row - if (trezorState.connectedDevice != null) { + if (trezorState.connectedDevice() != null) { SecondaryButton( text = "Disconnect", onClick = onDisconnect, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index aba53e9ed6..b4772d9785 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -31,10 +31,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.models.KnownDevice import to.bitkit.models.Toast import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType -import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorRepo import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorWalletMode @@ -443,7 +443,7 @@ class TrezorViewModel @Inject constructor( val signedStep = state.sendStep as? SendStep.Signed ?: return@launch val rawTx = signedStep.signedTx.serializedTx _uiState.update { it.copy(send = it.send.copy(isBroadcasting = true)) } - trezorRepo.broadcastRawTx(serializedTx = rawTx, network = state.selectedNetwork) + trezorRepo.broadcastRawTx(serializedTx = rawTx) .onSuccess { txid -> TrezorDebugLog.log("BROADCAST", "SUCCESS txid=$txid") _uiState.update { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt index c45d40ced5..9b1d45116f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HardwareWalletScreen.kt @@ -63,7 +63,7 @@ import to.bitkit.ui.theme.TopBarGradient fun HardwareWalletScreen( deviceId: String, onActivityItemClick: (String) -> Unit, - onTransferToSpendingClick: () -> Unit, + onTransferToSpendingClick: (String) -> Unit, onBackClick: () -> Unit, viewModel: HwWalletViewModel = hiltViewModel(), ) { @@ -96,13 +96,14 @@ private fun HardwareWalletContent( wallet: HwWallet, showRemoveDialog: Boolean, onActivityItemClick: (String) -> Unit, - onTransferToSpendingClick: () -> Unit, + onTransferToSpendingClick: (String) -> Unit, onRemoveClick: () -> Unit, onConfirmRemove: () -> Unit, onDismissRemoveDialog: () -> Unit, onBackClick: () -> Unit, ) { val hasFunds = wallet.balanceSats > 0uL + val hasFundingFunds = wallet.fundingBalanceSats > 0uL val hasActivity = wallet.activities.isNotEmpty() val showEmptyState = !hasFunds && !hasActivity @@ -169,10 +170,10 @@ private fun HardwareWalletContent( if (!showEmptyState) { item { VerticalSpacer(32.dp) } - if (hasFunds) { + if (hasFundingFunds) { item { SecondaryButton( - onClick = onTransferToSpendingClick, + onClick = { onTransferToSpendingClick(wallet.id) }, text = stringResource(R.string.wallet__transfer_to_spending), icon = { Icon( @@ -182,7 +183,7 @@ private fun HardwareWalletContent( ) }, hazeState = hazeState, - modifier = Modifier.testTag("HwTransferToSpending") + modifier = Modifier.testTag("HardwareTransferToSpending") ) } } @@ -264,7 +265,7 @@ private fun Preview() { wallet = previewWallet(), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -284,7 +285,7 @@ private fun PreviewNoActivity() { wallet = previewWallet(activities = persistentListOf()), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -304,7 +305,7 @@ private fun PreviewEmpty() { wallet = previewWallet(balanceSats = 0uL, activities = persistentListOf()), showRemoveDialog = false, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, @@ -324,7 +325,7 @@ private fun PreviewRemoveDialog() { wallet = previewWallet(), showRemoveDialog = true, onActivityItemClick = {}, - onTransferToSpendingClick = {}, + onTransferToSpendingClick = { _ -> }, onRemoveClick = {}, onConfirmRemove = {}, onDismissRemoveDialog = {}, diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index e68715ec31..c958cd8371 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -8,6 +8,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -37,7 +38,7 @@ class DeriveBalanceStateUseCase @Inject constructor( val channels = lightningRepo.getChannels().orEmpty() val activeTransfers = transferRepo.activeTransfers.first() - val paidOrdersSats = getOrderPaymentsSats(activeTransfers) + val paidOrdersSats = getOrderPaymentsSats(activeTransfers, channels, balanceDetails) val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) @@ -69,25 +70,41 @@ class DeriveBalanceStateUseCase @Inject constructor( } } - private fun getOrderPaymentsSats(transfers: List): ULong { - return transfers - .filter { it.type.isToSpending() && it.lspOrderId != null } - .sumOf { it.amountSats.toULong() } + private suspend fun getOrderPaymentsSats( + transfers: List, + channels: List, + balances: BalanceDetails, + ): ULong { + var amount = 0uL + val paidOrders = transfers.filter { it.type.isToSpending() && it.lspOrderId != null } + + for (transfer in paidOrders) { + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channelBalance = channelId?.let { id -> + balances.lightningBalances.find { it.channelId() == id } + } + if (channelBalance == null) { + amount = amount.safe() + transfer.amountSats.toULong().safe() + } + } + + return amount } - private fun getPendingChannelsSats( + private suspend fun getPendingChannelsSats( transfers: List, channels: List, balances: BalanceDetails, ): ULong { var amount = 0uL - val pendingTransfers = transfers.filter { it.type.isToSpending() && it.channelId != null } + val pendingTransfers = transfers.filter { it.type.isToSpending() } for (transfer in pendingTransfers) { - val channel = channels.find { it.channelId == transfer.channelId } + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channel = channels.find { it.channelId == channelId } if (channel != null && !channel.isChannelReady) { val channelBalance = balances.lightningBalances.find { it.channelId() == channel.channelId } - amount += channelBalance?.amountSats() ?: 0u + amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } } @@ -105,7 +122,7 @@ class DeriveBalanceStateUseCase @Inject constructor( for (transfer in toSavings) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } - toSavingsAmount += channelBalance?.amountSats() ?: 0u + toSavingsAmount = toSavingsAmount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } return toSavingsAmount @@ -134,7 +151,7 @@ class DeriveBalanceStateUseCase @Inject constructor( for (transfer in transfers.filter { it.type == TransferType.COOP_CLOSE }) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } - amount += channelBalance?.amountSats() ?: 0u + amount = amount.safe() + (channelBalance?.amountSats() ?: 0uL).safe() } return amount } @@ -157,6 +174,6 @@ class DeriveBalanceStateUseCase @Inject constructor( companion object { const val TAG = "DeriveBalanceStateUseCase" - const val FALLBACK_FEE_PERCENT = 0.1 + const val FALLBACK_FEE_PERCENT = Defaults.fallbackFeePercent } } diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index 9d2978537c..ec8c49ea6f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -28,10 +28,11 @@ import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.TransferRepo import to.bitkit.utils.Logger import javax.inject.Inject -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class ActivityDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -40,6 +41,7 @@ class ActivityDetailViewModel @Inject constructor( private val settingsStore: SettingsStore, private val blocktankRepo: BlocktankRepo, private val hwWalletRepo: HwWalletRepo, + private val transferRepo: TransferRepo, ) : ViewModel() { private val _txDetails = MutableStateFlow(null) val txDetails = _txDetails.asStateFlow() @@ -255,6 +257,14 @@ class ActivityDetailViewModel @Inject constructor( orders.firstOrNull { order -> order.payment?.onchain?.transactions?.any { it.txId == txId } == true }?.let { return@withContext it } + + val orderId = transferRepo.findLspOrderIdByFundingTxId(txId).getOrNull() + if (orderId != null) { + orders.find { it.id == orderId }?.let { return@withContext it } + blocktankRepo.getOrder(orderId, refresh = false).getOrNull()?.let { + return@withContext it + } + } } null diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 202ac4ee99..38116c390e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -7,7 +7,9 @@ import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.IBtOrder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -23,21 +25,27 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.math.min @@ -50,13 +58,14 @@ import kotlin.time.ExperimentalTime const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @OptIn(ExperimentalTime::class) @HiltViewModel class TransferViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, + private val hwWalletRepo: HwWalletRepo, private val walletRepo: WalletRepo, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, @@ -84,6 +93,7 @@ class TransferViewModel @Inject constructor( val transferEffects = MutableSharedFlow() fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } var maxLspFee = 0uL + private var hwTransferSignJob: Job? = null // region Spending @@ -226,22 +236,40 @@ class TransferViewModel @Inject constructor( channelId = order.channel?.shortChannelId, isMaxAmount = shouldUseSendAll, ) - .onSuccess { txId -> - cacheStore.addPaidOrder(orderId = order.id, txId = txId) - transferRepo.createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - lspOrderId = order.id, - ) - launch { walletRepo.syncBalances() } - launch { watchOrder(order.id) } - } + .onSuccess { txId -> fundPaidOrder(order, txId) } .onFailure { error -> ToastEventBus.send(error) } } } + /** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */ + private suspend fun fundPaidOrder( + order: IBtOrder, + txId: String, + createTransferActivity: Boolean = false, + fee: ULong = 0uL, + feeRate: ULong = 0uL, + ) { + cacheStore.addPaidOrder(orderId = order.id, txId = txId) + transferRepo.createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txId, + lspOrderId = order.id, + ) + if (createTransferActivity) { + transferRepo.createPendingToSpendingActivity( + order = order, + txId = txId, + fee = fee, + feeRate = feeRate, + ) + } + viewModelScope.launch { walletRepo.syncBalances() } + viewModelScope.launch { watchOrder(order.id) } + } + private suspend fun watchOrder(orderId: String): Result = runCatching { Logger.debug("Started watching order: '$orderId'", context = TAG) @@ -428,8 +456,214 @@ class TransferViewModel @Inject constructor( } fun resetSpendingState() { - _spendingUiState.value = TransferToSpendingUiState() - _transferValues.value = TransferValues() + hwTransferSignJob?.cancel() + hwTransferSignJob = null + _spendingUiState.update { TransferToSpendingUiState() } + _transferValues.update { TransferValues() } + } + + // endregion + + // region Hardware Wallet + + fun updateHwLimits(deviceId: String) { + viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } + + val account = hwWalletRepo.getFundingAccount(deviceId).getOrElse { + Logger.error("Failed to load hardware funding account", it, context = TAG) + _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } + setTransferEffect(TransferEffect.ToastException(it)) + return@launch + } + + awaitNodeRunning() + updateTransferValues(0uL) + + val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() + + val initialLspFees = estimateInitialLspFees(availableAmount) + if (initialLspFees == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() + estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) + } + } + + /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ + fun onTransferToSpendingHwConfirm(order: IBtOrder, deviceId: String) { + if (hwTransferSignJob?.isActive == true) return + + hwTransferSignJob = viewModelScope.launch { + _spendingUiState.update { it.copy(isSigning = true) } + try { + val address = order.payment?.onchain?.address.orEmpty() + if (address.isEmpty()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) + return@launch + } + + signTransferToSpendingWithHardware(order, deviceId, address) + .onSuccess { result -> + fundPaidOrder( + order = order, + txId = result.txId, + createTransferActivity = true, + fee = result.miningFeeSats, + feeRate = result.feeRate, + ) + setTransferEffect(TransferEffect.OnHwTxSigned) + } + .onFailure { handleHardwareTransferFailure(it, deviceId) } + } finally { + _spendingUiState.update { it.copy(isSigning = false) } + hwTransferSignJob = null + } + } + } + + private suspend fun signTransferToSpendingWithHardware( + order: IBtOrder, + deviceId: String, + address: String, + ): Result { + val result = runCatching { + ensureHardwareConnected(deviceId) + val satsPerVByte = hwFundingSatsPerVByte() + val funding = composeHardwareFundingTransaction( + deviceId = deviceId, + address = address, + sats = order.feeSat, + satsPerVByte = satsPerVByte, + ) + signAndBroadcastHardwareFunding(deviceId, funding) + } + result.exceptionOrNull()?.let { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + } + return result + } + + private suspend fun ensureHardwareConnected(deviceId: String) { + runCatching { + withTimeout(HW_RECONNECT_TIMEOUT) { + hwWalletRepo.ensureConnected(deviceId).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareReconnectError(it) + } + } + + private suspend fun composeHardwareFundingTransaction( + deviceId: String, + address: String, + sats: ULong, + satsPerVByte: ULong, + ): HwFundingTransaction { + return runCatching { + withTimeout(HW_COMPOSE_TIMEOUT) { + hwWalletRepo.composeFundingTransaction( + deviceId = deviceId, + address = address, + sats = sats, + satsPerVByte = satsPerVByte, + ).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + throw HardwareFundingError(it) + } + } + + @Suppress("ThrowsCount") + private suspend fun signAndBroadcastHardwareFunding( + deviceId: String, + funding: HwFundingTransaction, + ): HwFundingBroadcastResult { + return runCatching { + withTimeout(HW_SIGN_TIMEOUT) { + hwWalletRepo.signAndBroadcastFunding( + deviceId = deviceId, + funding = funding, + ).getOrThrow() + } + }.getOrElse { + if (it is CancellationException && it !is TimeoutCancellationException) throw it + if (it is TimeoutCancellationException) { + hwWalletRepo.disconnectStaleSession(deviceId) + throw HardwareSigningTimeoutError(it) + } + throw it + } + } + + private suspend fun handleHardwareTransferFailure(e: Throwable, deviceId: String) { + when (e) { + is HardwareReconnectError -> { + Logger.error("Failed to reconnect hardware device", e, context = TAG) + showHardwareReconnectError() + } + is HardwareSigningTimeoutError -> { + Logger.warn("Timed out hardware transfer signing for '$deviceId'", e, context = TAG) + showHardwareTimeoutError() + } + is HardwareFundingError -> { + Logger.warn("Failed to compose hardware transfer funding for '$deviceId'", e, context = TAG) + showHardwareFundingError(e) + } + else -> { + Logger.error("Hardware transfer failed", e, context = TAG) + ToastEventBus.send(e) + } + } + } + + private suspend fun showHardwareReconnectError() { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.lightning__transfer_hw__reconnect_error_title), + description = context.getString(R.string.lightning__transfer_hw__reconnect_error_description), + ) + } + + private suspend fun showHardwareTimeoutError() { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.wallet__toast_payment_failed_timeout), + ) + } + + private suspend fun showHardwareFundingError(e: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = e.cause?.message ?: e.message ?: context.getString(R.string.common__error_body), + ) + } + + private suspend fun hwFundingFeeReserve(balanceSats: ULong): ULong { + val satsPerVByte = fetchHwFundingSatsPerVByte().getOrNull() + ?: return hwFundingFallbackFeeReserve(balanceSats) + return satsPerVByte.safe() * HW_FUNDING_TX_VBYTES.safe() + } + + private fun hwFundingFallbackFeeReserve(balanceSats: ULong): ULong { + val minReserve = HW_FUNDING_FALLBACK_SATS_PER_VBYTE.safe() * HW_FUNDING_TX_VBYTES.safe() + val fallback = (balanceSats.toDouble() * Defaults.fallbackFeePercent).toULong() + return maxOf(minReserve, fallback) + } + + private suspend fun hwFundingSatsPerVByte(): ULong = + fetchHwFundingSatsPerVByte().getOrDefault(HW_FUNDING_FALLBACK_SATS_PER_VBYTE) + + private suspend fun fetchHwFundingSatsPerVByte(): Result { + val speed = settingsStore.data.first().defaultTransactionSpeed + return lightningRepo.getFeeRateForSpeed(speed) } // endregion @@ -638,6 +872,21 @@ class TransferViewModel @Inject constructor( private const val MIN_STEP_DELAY_MS = 500L private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 + + /** Conservative vbyte reserve for multi-input hardware funding before exact compose runs. */ + private const val HW_FUNDING_TX_VBYTES = 1_200uL + + /** Minimum fallback fee rate when fee estimates are temporarily unavailable. */ + private const val HW_FUNDING_FALLBACK_SATS_PER_VBYTE = 1uL + + /** Upper bound for reconnecting a known device before the UI asks for reconnect. */ + private val HW_RECONNECT_TIMEOUT = 30.seconds + + /** Upper bound for exact hardware funding composition before signing starts. */ + private val HW_COMPOSE_TIMEOUT = 45.seconds + + /** Upper bound for one hardware signing attempt before the UI releases the button. */ + private val HW_SIGN_TIMEOUT = 120.seconds const val LN_SETUP_STEP_0 = 0 const val LN_SETUP_STEP_1 = 1 const val LN_SETUP_STEP_2 = 2 @@ -645,6 +894,10 @@ class TransferViewModel @Inject constructor( } } +private class HardwareReconnectError(cause: Throwable) : AppError(cause) +private class HardwareFundingError(cause: Throwable) : AppError(cause) +private class HardwareSigningTimeoutError(cause: Throwable) : AppError(cause) + // region state data class TransferToSpendingUiState( val order: IBtOrder? = null, @@ -654,6 +907,7 @@ data class TransferToSpendingUiState( val balanceAfterFee: Long = 0, val quarterAmount: Long = 0, val isLoading: Boolean = false, + val isSigning: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) @@ -667,6 +921,7 @@ data class TransferValues( sealed interface TransferEffect { data object OnOrderCreated : TransferEffect + data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9df11ac3a8..c7a3a3a97e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -364,6 +364,11 @@ Custom <accent>fee</accent> Transfer Funds Swipe To Transfer + Open Trezor Connect + Please reconnect your hardware device, it appears to be disconnected from your phone. + Reconnect Hardware Device + Sign with\n<accent>your device</accent> + Transaction\n<accent>signed</accent> TRANSFER IN PROGRESS TRANSFER READY IN %s Get Started diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 3d31ef72f0..d65386de54 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -8,9 +8,12 @@ import com.synonym.bitkitcore.PaymentType import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.whenever @@ -32,6 +35,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { private val blocktankRepo = mock() private val settingsStore = mock() private val hwWalletRepo = mock() + private val transferRepo = mock() companion object Fixtures { const val ACTIVITY_ID = "test-activity-1" @@ -45,6 +49,9 @@ class ActivityDetailViewModelTest : BaseUnitTest() { whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis())) whenever(hwWalletRepo.activities).thenReturn(MutableStateFlow(persistentListOf())) + runBlocking { + whenever(transferRepo.findLspOrderIdByFundingTxId(any())).thenReturn(Result.success(null)) + } sut = ActivityDetailViewModel( context = context, @@ -53,6 +60,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { blocktankRepo = blocktankRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, + transferRepo = transferRepo, ) } @@ -153,6 +161,19 @@ class ActivityDetailViewModelTest : BaseUnitTest() { assertNull(result) } + @Test + fun `findOrderForTransfer finds pending order by transfer funding txId`() = test { + val txId = "funding-tx-id" + val order = mock { on { id } doReturn ORDER_ID } + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(orders = persistentListOf()))) + whenever(transferRepo.findLspOrderIdByFundingTxId(txId)).thenReturn(Result.success(ORDER_ID)) + whenever(blocktankRepo.getOrder(eq(ORDER_ID), eq(false))).thenReturn(Result.success(order)) + + val result = sut.findOrderForTransfer(null, txId) + + assertEquals(order, result) + } + @Test fun `loadActivity starts observation of activity changes`() = test { val initialActivity = createTestActivity(ACTIVITY_ID, confirmed = false) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 31f1219741..286fcdd677 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -15,6 +15,7 @@ import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -261,6 +262,77 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(testActivity, result.getOrThrow()) } + @Test + fun `syncHardwareOnchainActivity confirms existing transfer and preserves metadata`() = test { + val existing = createOnchainActivity( + id = "transfer-txid", + txId = "transfer-txid", + value = 50_000uL, + fee = 0uL, + feeRate = 2uL, + address = "bc1qlsp", + confirmed = false, + timestamp = 1_000uL, + isTransfer = true, + channelId = "channel-1", + isBoosted = true, + boostTxIds = listOf("boost-txid"), + contact = "contact", + ).v1 + val watcher = OnchainActivity.create( + id = "transfer-txid", + txType = PaymentType.SENT, + txId = "transfer-txid", + value = 49_000uL, + fee = 1_250uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + ) + whenever(coreService.activity.getOnchainActivityByTxId("transfer-txid")).thenReturn(existing) + + val result = sut.syncHardwareOnchainActivity(watcher) + + assertTrue(result.isSuccess) + val captor = argumentCaptor() + verify(coreService.activity).update(eq("transfer-txid"), captor.capture()) + val updated = (captor.firstValue as Activity.Onchain).v1 + assertTrue(updated.confirmed) + assertEquals(2_000uL, updated.confirmTimestamp) + assertEquals(true, updated.doesExist) + assertEquals(50_000uL, updated.value) + assertEquals(1_250uL, updated.fee) + assertEquals(2uL, updated.feeRate) + assertEquals("bc1qlsp", updated.address) + assertEquals(true, updated.isTransfer) + assertEquals("channel-1", updated.channelId) + assertEquals(true, updated.isBoosted) + assertEquals(listOf("boost-txid"), updated.boostTxIds) + assertEquals("contact", updated.contact) + } + + @Test + fun `syncHardwareOnchainActivity ignores hardware tx that is not in main activities`() = test { + val watcher = OnchainActivity.create( + id = "hardware-only-txid", + txType = PaymentType.RECEIVED, + txId = "hardware-only-txid", + value = 10_000uL, + fee = 0uL, + address = "", + timestamp = 2_000uL, + confirmed = true, + ) + whenever(coreService.activity.getOnchainActivityByTxId("hardware-only-txid")).thenReturn(null) + + val result = sut.syncHardwareOnchainActivity(watcher) + + assertTrue(result.isSuccess) + verify(coreService.activity, never()).update(any(), any()) + verify(coreService.activity, never()).insert(any()) + verify(coreService.activity, never()).upsert(any()) + } + @Test fun `getActivity returns null when not found`() = test { val activityId = "activity123" diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 5ee3bf89f5..50cf626b1d 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -2,9 +2,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.HistoryTransaction import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TxDirection import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent @@ -12,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.Before @@ -29,9 +32,12 @@ import to.bitkit.data.HwWalletStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWalletReceivedTx +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toTrezorCoinType import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -46,6 +52,7 @@ import kotlin.time.Instant class HwWalletRepoTest : BaseUnitTest() { private val trezorRepo = mock() + private val activityRepo = mock() private val hwWalletStore = mock() private val settingsStore = mock() private val clock = mock() @@ -76,10 +83,20 @@ class HwWalletRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(trezorRepo.state).thenReturn(trezorState) whenever(trezorRepo.watcherEvents).thenReturn(watcherEvents) + runBlocking { + whenever(activityRepo.syncHardwareOnchainActivity(any())).thenReturn(Result.success(Unit)) + } whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(1_700_000_000)) } - private fun createRepo() = HwWalletRepo(trezorRepo, hwWalletStore, settingsStore, clock, testDispatcher) + private fun createRepo() = HwWalletRepo( + trezorRepo = trezorRepo, + activityRepo = activityRepo, + hwWalletStore = hwWalletStore, + settingsStore = settingsStore, + clock = clock, + ioDispatcher = testDispatcher, + ) @Test fun `lists a known device with zero balance before any watcher event`() = test { @@ -141,6 +158,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1, wallet.activities.size) assertEquals(1, sut.activities.value.size) assertEquals(Activity.Onchain::class, wallet.activities.single()::class) + verify(activityRepo).syncHardwareOnchainActivity((wallet.activities.single() as Activity.Onchain).v1) } @Test @@ -166,7 +184,9 @@ class HwWalletRepoTest : BaseUnitTest() { ) ) - assertEquals(150uL, sut.wallets.value.single().balanceSats) + val wallet = sut.wallets.value.single() + assertEquals(150uL, wallet.balanceSats) + assertEquals(100uL, wallet.fundingBalanceSats) assertEquals(150uL, sut.totalSats.value) } @@ -509,6 +529,7 @@ class HwWalletRepoTest : BaseUnitTest() { val wallet = sut.wallets.value.single() assertEquals(421_900uL, wallet.balanceSats) + assertEquals(421_900uL, wallet.fundingBalanceSats) assertEquals(421_900uL, sut.totalSats.value) assertEquals(1, wallet.activities.size) assertEquals(setOf("ble1", "usb1"), wallet.deviceIds) @@ -703,6 +724,118 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo).onAppForegrounded() } + @Test + fun `composeFundingTransaction returns composed fee data`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val composeResult = ComposeResult.Success( + psbt = "psbt", + fee = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + ) + whenever( + trezorRepo.composeTransaction( + extendedKey = any(), + outputs = any(), + feeRates = any(), + network = any(), + accountType = anyOrNull(), + coinSelection = any(), + ) + ).thenReturn(Result.success(listOf(composeResult))) + val sut = createRepo() + + val result = sut.composeFundingTransaction( + deviceId = "dev1", + address = "bc1qtest", + sats = 25_000uL, + satsPerVByte = 2uL, + ) + + assertEquals(true, result.isSuccess) + assertEquals("psbt", result.getOrThrow().psbt) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + assertEquals(2uL, result.getOrThrow().satsPerVByte) + } + + @Test + fun `composeFundingTransaction does not sign when compose fails`() = test { + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever( + trezorRepo.composeTransaction( + extendedKey = any(), + outputs = any(), + feeRates = any(), + network = any(), + accountType = anyOrNull(), + coinSelection = any(), + ) + ).thenReturn(Result.failure(AppError("compose failed"))) + val sut = createRepo() + + val result = sut.composeFundingTransaction( + deviceId = "dev1", + address = "bc1qtest", + sats = 25_000uL, + satsPerVByte = 2uL, + ) + + assertEquals(true, result.isFailure) + verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) + verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).disconnectStaleSession(any()) + } + + @Test + fun `signAndBroadcastFunding returns txid and composed fee data`() = test { + val signedTx = TrezorSignedTx( + signatures = emptyList(), + serializedTx = "rawtx", + txid = "signed-txid", + ) + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 3.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) + .thenReturn(Result.success(signedTx)) + whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding("dev1", funding) + + assertEquals(true, result.isSuccess) + assertEquals("broadcast-txid", result.getOrThrow().txId) + assertEquals(1_250uL, result.getOrThrow().miningFeeSats) + assertEquals(3uL, result.getOrThrow().feeRate) + assertEquals(26_250uL, result.getOrThrow().totalSpent) + } + + @Test + fun `signAndBroadcastFunding disconnects stale session when sign fails`() = test { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = 1_250uL, + feeRate = 2.0f, + totalSpent = 26_250uL, + satsPerVByte = 2uL, + ) + whenever(trezorRepo.signTxFromPsbt("psbt", Env.network.toTrezorCoinType())) + .thenReturn(Result.failure(AppError("sign failed"))) + whenever(trezorRepo.disconnectStaleSession("dev1")).thenReturn(Result.success(Unit)) + val sut = createRepo() + + val result = sut.signAndBroadcastFunding("dev1", funding) + + assertEquals(true, result.isFailure) + verify(trezorRepo).disconnectStaleSession("dev1") + verify(trezorRepo, never()).broadcastRawTx(any()) + } + @Test fun `forwards pairing code calls to the trezor repo`() = test { val sut = createRepo() diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 68f6993443..68104ccb9c 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -273,6 +273,51 @@ class TransferRepoTest : BaseUnitTest() { verify(transferDao, never()).markSettled(any(), any()) } + @Test + fun `syncTransferStates persists resolved channel and marks activity for TO_SPENDING transfer`() = test { + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.TO_SPENDING, + amountSats = 50000L, + channelId = null, + fundingTxId = fundingTxo.txid, + lspOrderId = ID_ORDER, + isSettled = false, + createdAt = 1000L, + ) + val channelDetails = createChannelDetails().copy( + channelId = ID_CHANNEL, + fundingTxo = fundingTxo, + isChannelReady = false, + ) + val activity = OnchainActivity.create( + id = fundingTxo.txid, + txType = PaymentType.SENT, + txId = fundingTxo.txid, + value = 50_000uL, + fee = 0uL, + address = "bc1qtest", + timestamp = 1000uL, + isTransfer = true, + channelId = null, + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(listOf(channelDetails)) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(mock())) + whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)).thenReturn(Result.success(null)) + whenever(activityService.getOnchainActivityByTxId(fundingTxo.txid)).thenReturn(activity) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).update(transfer.copy(channelId = ID_CHANNEL)) + verify(activityService).update( + eq(activity.id), + eq(Activity.Onchain(activity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) + } + @Test fun `syncTransferStates does not settle TO_SPENDING transfer when channel not found`() = test { val transfer = TransferEntity( @@ -794,6 +839,34 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(ID_CHANNEL, result) } + @Test + fun `resolveChannelIdForTransfer finds channel via transfer funding tx when order lacks channel data`() = test { + val order = mock() + whenever(order.channel).thenReturn(null) + + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.TO_SPENDING, + amountSats = 50000L, + channelId = null, + fundingTxId = fundingTxo.txid, + lspOrderId = ID_ORDER, + isSettled = false, + createdAt = 1000L, + ) + + val channelDetails = mock() + whenever(channelDetails.fundingTxo).thenReturn(fundingTxo) + whenever(channelDetails.channelId).thenReturn(ID_CHANNEL) + + whenever(blocktankRepo.getOrder(ID_ORDER, refresh = false)) + .thenReturn(Result.success(order)) + + val result = sut.resolveChannelIdForTransfer(transfer, listOf(channelDetails)) + + assertEquals(ID_CHANNEL, result) + } + @Test fun `resolveChannelIdForTransfer returns null when LSP order not found`() = test { val transfer = TransferEntity( diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 71c6f82985..9c0dc0f612 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -2,13 +2,18 @@ package to.bitkit.repositories import android.content.Context import android.content.SharedPreferences +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeParams import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorTransportType +import com.synonym.bitkitcore.TrezorTransportWriteResult import com.synonym.bitkitcore.WalletSelection +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -27,14 +32,20 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.HwWalletStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.env.Env +import to.bitkit.models.KnownDevice import to.bitkit.models.TransportType +import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.services.TrezorUiHandler import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -65,8 +76,10 @@ class TrezorRepoTest : BaseUnitTest() { private val trezorTransport = mock() private val trezorUiHandler = mock() private val hwWalletStore = mock() + private val settingsStore = mock() private val prefs = mock() private val prefsEditor = mock() + private val settingsData = MutableStateFlow(SettingsData()) private lateinit var sut: TrezorRepo @@ -81,8 +94,12 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) whenever(trezorTransport.transportRestored).thenReturn(MutableSharedFlow()) whenever(trezorTransport.hasUsbPermission(any())).thenReturn(true) + whenever(trezorTransport.disconnectDevice(any())).thenReturn( + TrezorTransportWriteResult(success = true, error = "") + ) whenever(trezorUiHandler.needsPinEntry).thenReturn(MutableStateFlow(false)) whenever(trezorUiHandler.currentSelection()).thenReturn(WalletSelection.Standard) + whenever(settingsStore.data).thenReturn(settingsData) whenever(context.filesDir).thenReturn(tempFolder.root) whenever { hwWalletStore.loadKnownDevices() }.thenReturn(emptyList()) } @@ -93,6 +110,7 @@ class TrezorRepoTest : BaseUnitTest() { trezorTransport = trezorTransport, trezorUiHandler = trezorUiHandler, hwWalletStore = hwWalletStore, + settingsStore = settingsStore, clock = Clock.System, ioDispatcher = testDispatcher, ) @@ -147,6 +165,7 @@ class TrezorRepoTest : BaseUnitTest() { transportType: TransportType = TransportType.USB, xpubs: Map = emptyMap(), customLabel: String? = null, + walletId: String = "wallet-id", ) = KnownDevice( id = id, name = name, @@ -157,6 +176,7 @@ class TrezorRepoTest : BaseUnitTest() { lastConnectedAt = 123L, xpubs = xpubs, customLabel = customLabel, + walletId = walletId, ) // region initialize @@ -174,6 +194,23 @@ class TrezorRepoTest : BaseUnitTest() { assertNull(sut.state.value.error) } + @Test + fun `initialize assigns wallet ids to restored devices missing them`() = test { + val knownDevice = mockKnownDevice(walletId = "") + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + val savedCaptor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(savedCaptor.capture()) + val saved = savedCaptor.firstValue.single() + assertEquals(knownDevice.id, saved.id) + assertNotNull(UUID.fromString(saved.walletId)) + assertEquals(listOf(saved), sut.state.value.knownDevices) + } + @Test fun `initialize should reuse completed setup`() = test { sut = createSut() @@ -517,8 +554,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(features, sut.state.value.connectedDevice) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(features, sut.state.value.connectedDevice()) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) assertFalse(sut.state.value.isConnecting) } @@ -541,6 +578,48 @@ class TrezorRepoTest : BaseUnitTest() { assertEquals(TransportType.USB, saved.transportType) assertEquals("Savings", saved.label) assertEquals("Safe 5", saved.model) + assertNotNull(UUID.fromString(saved.walletId)) + } + + @Test + fun `connect reuses wallet id from same xpub wallet`() = test { + val walletId = "hardware-wallet-id" + val nativeSegwitPath = "m/84'/1'/0'" + val previousDevice = mockKnownDevice( + id = "ble-device", + path = "ble:AA:BB", + transportType = TransportType.BLUETOOTH, + xpubs = mapOf("nativeSegwit" to "same-native-xpub"), + walletId = walletId, + ) + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(previousDevice)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever( + trezorService.getPublicKey( + path = any(), + coin = anyOrNull(), + showOnTrezor = eq(false), + ) + ).thenAnswer { + val path = it.getArgument(0) + if (path == nativeSegwitPath) { + mockPublicKeyResponse(xpub = "same-native-xpub", path = nativeSegwitPath) + } else { + throw AppError("xpub failed") + } + } + sut = createSut() + + sut.scan() + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + val captor = argumentCaptor>() + verify(hwWalletStore).saveKnownDevices(captor.capture()) + assertEquals(setOf(walletId), captor.firstValue.map { it.walletId }.toSet()) } @Test @@ -624,6 +703,21 @@ class TrezorRepoTest : BaseUnitTest() { verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) } + @Test + fun `connect should disconnect stale session after retryable THP failures`() = test { + whenever(trezorService.connect(eq(DEVICE_ID), any())) + .thenThrow(RuntimeException("thp timeout")) + .thenThrow(RuntimeException("session timeout")) + sut = createSut() + + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isFailure) + assertNull(sut.state.value.connected) + verify(trezorService, times(2)).connect(eq(DEVICE_ID), any()) + verify(trezorService).disconnect() + } + @Test fun `connect should not retry non-retryable errors`() = test { whenever(trezorService.connect(eq(DEVICE_ID), any())).thenThrow(RuntimeException("bad pin")) @@ -661,13 +755,13 @@ class TrezorRepoTest : BaseUnitTest() { sut.scan() sut.connect(DEVICE_ID) - assertEquals(features, sut.state.value.connectedDevice) + assertEquals(features, sut.state.value.connectedDevice()) val result = sut.disconnect() assertTrue(result.isSuccess) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.lastAddress) assertNull(sut.state.value.lastPublicKey) } @@ -707,8 +801,8 @@ class TrezorRepoTest : BaseUnitTest() { val result = sut.disconnect() assertTrue(result.isFailure) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.lastAddress) assertNull(sut.state.value.lastPublicKey) assertEquals("disconnect failed", sut.state.value.error) @@ -729,12 +823,32 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(sut.state.value.knownDevices.isEmpty()) assertTrue(sut.state.value.nearbyDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) + assertNull(sut.state.value.connectedDevice()) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) verify(hwWalletStore).reset() } + @Test + fun `resetState disconnects connected transport session`() = test { + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + sut.scan() + sut.connect(DEVICE_ID) + sut.resetState() + + verify(trezorService).disconnect() + verify(trezorTransport).disconnectDevice(DEVICE_PATH) + assertNull(sut.state.value.connectedDevice()) + } + @Test fun `resetState clears initialized setup gate`() = test { val devices = listOf(mockDeviceInfo()) @@ -869,6 +983,34 @@ class TrezorRepoTest : BaseUnitTest() { // endregion + // region composeTransaction + + @Test + fun `composeTransaction should use configured electrum server`() = test { + val electrumServer = "ssl://custom.example:50002" + settingsData.value = SettingsData(electrumServer = electrumServer) + whenever(trezorService.isConnected()).thenReturn(true) + whenever(trezorService.getDeviceFingerprint()).thenReturn("fingerprint") + whenever(trezorService.composeTransaction(any())).thenReturn(emptyList()) + sut = createSut() + + val result = sut.composeTransaction( + extendedKey = "vpub", + outputs = listOf(ComposeOutput.Payment(address = TEST_ADDRESS, amountSats = 100uL)), + feeRates = listOf(1f), + network = Env.network.toCoreNetwork(), + accountType = null, + coinSelection = CoinSelection.BRANCH_AND_BOUND, + ) + + val params = argumentCaptor() + assertTrue(result.isSuccess) + verify(trezorService).composeTransaction(params.capture()) + assertEquals(electrumServer, params.firstValue.wallet.electrumUrl) + } + + // endregion + // region hasKnownDevices @Test @@ -917,7 +1059,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) assertFalse(sut.state.value.isAutoReconnecting) } @@ -940,7 +1082,85 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(features, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) + } + + @Test + fun `connectKnownDevice should close stale session when forced`() = test { + val knownDevice = mockKnownDevice() + val device = mockDeviceInfo() + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(listOf(device)) + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.connectKnownDevice(DEVICE_ID, forceSession = true) + + assertTrue(result.isSuccess) + verify(trezorService).disconnect() + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) + } + + @Test + fun `connectKnownDevice should use stored bluetooth device when scan misses active connection`() = test { + val bleDeviceId = "ble:57:21:A7:F9:DD:AD" + val knownDevice = mockKnownDevice( + id = bleDeviceId, + path = bleDeviceId, + transportType = TransportType.BLUETOOTH, + ) + val features = mockFeatures() + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(knownDevice)) + whenever(trezorService.scan()).thenReturn(emptyList()) + whenever(trezorService.connect(eq(bleDeviceId), any())).thenReturn(features) + sut = createSut() + + sut.initialize() + val result = sut.connectKnownDevice(bleDeviceId) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + assertEquals(bleDeviceId, sut.state.value.connectedDeviceId()) + verify(trezorService).connect(eq(bleDeviceId), any()) + } + + @Test + fun `connectKnownDevice should rethrow cancellation and clear connecting state`() = test { + val cancellation = CancellationException("cancelled") + whenever(trezorService.scan()).thenAnswer { throw cancellation } + sut = createSut() + + sut.initialize() + val thrown = assertFailsWith { + sut.connectKnownDevice(DEVICE_ID) + } + + assertEquals(cancellation.message, thrown.message) + assertFalse(sut.state.value.isConnecting) + assertNull(sut.state.value.error) + } + + @Test + fun `ensureConnected returns current selected device without reconnecting`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorService.connect(eq(DEVICE_ID), any())).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + whenever(trezorService.isConnected()).thenReturn(true) + + val result = sut.ensureConnected(DEVICE_ID) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + verify(trezorService, times(1)).scan() + verify(trezorService, times(1)).connect(eq(DEVICE_ID), any()) + verify(trezorService, never()).disconnect() } // endregion @@ -1006,7 +1226,7 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(addressResponse, result.getOrNull()) - assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId()) verify(trezorService).scan() verify(trezorService).connect(eq(DEVICE_ID), any()) } @@ -1034,8 +1254,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertTrue(sut.state.value.knownDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertNull(sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) verify(trezorService).clearCredentials(DEVICE_ID) @@ -1061,8 +1281,8 @@ class TrezorRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertTrue(sut.state.value.knownDevices.isEmpty()) - assertNull(sut.state.value.connectedDevice) - assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.connectedDevice()) + assertNull(sut.state.value.connectedDeviceId()) assertEquals("clear failed", result.exceptionOrNull()?.message) assertEquals("clear failed", sut.state.value.error) verify(trezorTransport).clearDeviceCredential(DEVICE_ID) @@ -1100,8 +1320,8 @@ class TrezorRepoTest : BaseUnitTest() { assertFalse(state.isAutoReconnecting) assertTrue(state.knownDevices.isEmpty()) assertTrue(state.nearbyDevices.isEmpty()) - assertNull(state.connectedDevice) - assertNull(state.connectedDeviceId) + assertNull(state.connectedDevice()) + assertNull(state.connectedDeviceId()) assertNull(state.lastAddress) assertNull(state.lastPublicKey) assertNull(state.error) diff --git a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt index 044b16c05c..254089fb7d 100644 --- a/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt +++ b/app/src/test/java/to/bitkit/services/TrezorBridgeTransportTest.kt @@ -134,10 +134,76 @@ class TrezorBridgeTransportTest { assertTrue(result.error.contains("shorter")) } - private fun createSut(enabled: Boolean = true): TrezorBridgeTransport { + @Test + fun `signing call uses longer read timeout than bridge management requests`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 15u, byteArrayOf()) + + assertTrue(result.success) + assertEquals(18u.toUShort(), result.messageType) + assertEquals(listOf(0x01), result.data.toList()) + } + + @Test + fun `non signing call uses bridge management read timeout`() { + server.route = { request -> + when { + request.path == "/enumerate" -> { + TestHttpResponse("""[{"path":"emulator:21324","session":null}]""") + } + request.path == "/acquire/emulator%3A21324/null" -> { + TestHttpResponse("""{"session":"session-1"}""") + } + request.path == "/call/session-1" -> { + TestHttpResponse( + body = frame(18u.toUShort(), byteArrayOf(0x01)), + delayMs = 200, + ) + } + else -> TestHttpResponse("{}", statusCode = 404) + } + } + + val sut = createSut(readTimeoutMs = 50, callReadTimeoutMs = 1_000) + val device = sut.enumerateDevices().single() + assertTrue(sut.openDevice(device.path).success) + + val result = sut.callMessage(device.path, 55u, byteArrayOf()) + + assertFalse(result.success) + } + + private fun createSut( + enabled: Boolean = true, + readTimeoutMs: Int = 30_000, + callReadTimeoutMs: Int = 120_000, + ): TrezorBridgeTransport { return TrezorBridgeTransport( baseUrl = "http://127.0.0.1:${server.port}", enabled = enabled, + readTimeoutMs = readTimeoutMs, + callReadTimeoutMs = callReadTimeoutMs, ) } @@ -159,6 +225,7 @@ class TrezorBridgeTransportTest { private data class TestHttpResponse( val body: String, val statusCode: Int = 200, + val delayMs: Long = 0L, ) private class TestHttpServer { @@ -213,6 +280,7 @@ class TrezorBridgeTransportTest { ) requests.add("${request.method} ${request.path}") val response = route(request) + if (response.delayMs > 0) Thread.sleep(response.delayMs) val responseBytes = response.body.toByteArray() val responseHeaders = ( "HTTP/1.1 ${response.statusCode} OK\r\n" + diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt new file mode 100644 index 0000000000..791ba05196 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -0,0 +1,20 @@ +package to.bitkit.ui + +import org.junit.Test +import kotlin.test.assertEquals + +class ContentViewTest { + @Test + fun `spending start route uses intro until seen`() { + assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false)) + assertEquals(Routes.SpendingAmount, transferSpendingStartRoute(hasSeenSpendingIntro = true)) + } + + @Test + fun `hardware spending start route keeps device id after intro`() { + val deviceId = "trezor-1" + + assertEquals(Routes.SpendingIntroHw(deviceId), transferSpendingStartRoute(false, deviceId)) + assertEquals(Routes.SpendingAmountHw(deviceId), transferSpendingStartRoute(true, deviceId)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index f0bd80f635..66d1a9d944 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -262,14 +262,14 @@ class TrezorViewModelTest : BaseUnitTest() { sut.broadcastSignedTx() advanceUntilIdle() - verify(trezorRepo, never()).broadcastRawTx(any(), any()) + verify(trezorRepo, never()).broadcastRawTx(any()) } @Test fun `broadcastSignedTx should not restore signed step after reset`() = test { loadSignedTx() val broadcastResult = CompletableDeferred>() - whenever(trezorRepo.broadcastRawTx(any(), any())) + whenever(trezorRepo.broadcastRawTx(any())) .doSuspendableAnswer { broadcastResult.await() } sut.broadcastSignedTx() @@ -293,7 +293,7 @@ class TrezorViewModelTest : BaseUnitTest() { val broadcastResults = ArrayDeque( listOf(firstBroadcastResult, secondBroadcastResult) ) - whenever(trezorRepo.broadcastRawTx(any(), any())) + whenever(trezorRepo.broadcastRawTx(any())) .doSuspendableAnswer { broadcastResults.removeFirst().await() } sut.broadcastSignedTx() diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 585eb8f0ff..a5ad9620f7 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -10,6 +10,7 @@ import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BalanceSource import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.LightningBalance +import org.lightningdevkit.ldknode.OutPoint import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn @@ -46,6 +47,10 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) wheneverBlocking { lightningRepo.getChannelFundableBalance() }.thenReturn(0uL) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenAnswer { invocation -> + val transfer = invocation.getArgument(0) + transfer.channelId + } wheneverBlocking { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) }.thenReturn(Result.success(1000uL)) @@ -208,6 +213,89 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should move LSP order transfer to pending channel once LDK reports the balance`() = test { + val channelId = "lsp-pending-channel-id" + val amountSats = 50_000uL + val channelBalance = newChannelBalance(channelId, amountSats) + val balance = newBalanceDetails().copy( + lightningBalances = listOf(channelBalance), + totalLightningBalanceSats = amountSats, + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + + val channel = mock { + on { this.channelId } doReturn channelId + on { isChannelReady } doReturn false + } + val transfers = listOf( + newTransferEntity( + type = TransferType.TO_SPENDING, + amountSats = amountSats.toLong(), + channelId = null, + lspOrderId = "lsp-order-id", + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenReturn(channelId) + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(amountSats, balanceState.balanceInTransferToSpending) + assertEquals( + 0uL, + balanceState.totalLightningSats, + "Lightning balance reduced while the discovered LSP channel is still pending" + ) + } + + @Test + fun `should not double count LSP transfer when pending channel resolves before order refresh`() = test { + val channelId = "lsp-pending-channel-id" + val fundingTxId = "funding-tx-id" + val amountSats = 50_000uL + val channelBalance = newChannelBalance(channelId, amountSats) + val balance = newBalanceDetails().copy( + lightningBalances = listOf(channelBalance), + totalLightningBalanceSats = amountSats, + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance)) + + val channel = mock { + on { this.channelId } doReturn channelId + on { isChannelReady } doReturn false + on { fundingTxo } doReturn OutPoint(txid = fundingTxId, vout = 0u) + } + val transfers = listOf( + newTransferEntity( + type = TransferType.TO_SPENDING, + amountSats = amountSats.toLong(), + channelId = null, + fundingTxId = fundingTxId, + lspOrderId = "lsp-order-id", + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(listOf(channel)) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + whenever(transferRepo.resolveChannelIdForTransfer(any(), any())).thenAnswer { invocation -> + val transfer = invocation.getArgument(0) + val channels = invocation.getArgument>(1) + channels.find { it.fundingTxo?.txid == transfer.fundingTxId }?.channelId + } + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(amountSats, balanceState.balanceInTransferToSpending) + assertEquals(0uL, balanceState.totalLightningSats) + } + @Test fun `should not count manual channel as pending when ready`() = test { newBalanceDetails() @@ -478,13 +566,14 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { type: TransferType, amountSats: Long, channelId: String? = null, + fundingTxId: String? = null, lspOrderId: String? = null, ) = TransferEntity( id = "test-transfer-${System.currentTimeMillis()}", type = type, amountSats = amountSats, channelId = channelId, - fundingTxId = null, + fundingTxId = fundingTxId, lspOrderId = lspOrderId, isSettled = false, createdAt = System.currentTimeMillis(), diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index e4472bdabd..f6bc83f01e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -5,28 +5,48 @@ import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import com.synonym.bitkitcore.TrezorFeatures +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeStatus import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.models.BalanceState +import to.bitkit.models.HwFundingAccount +import to.bitkit.models.HwFundingAddressType +import to.bitkit.models.HwFundingBroadcastResult +import to.bitkit.models.HwFundingTransaction +import to.bitkit.models.HwWallet +import to.bitkit.models.TransferType +import to.bitkit.models.TransportType +import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.time.Clock @@ -39,6 +59,7 @@ class TransferViewModelTest : BaseUnitTest() { private val context = mock() private val lightningRepo = mock() private val blocktankRepo = mock() + private val hwWalletRepo = mock() private val walletRepo = mock() private val settingsStore = mock() private val cacheStore = mock() @@ -66,6 +87,7 @@ class TransferViewModelTest : BaseUnitTest() { context = context, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + hwWalletRepo = hwWalletRepo, walletRepo = walletRepo, settingsStore = settingsStore, cacheStore = cacheStore, @@ -126,6 +148,199 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) } + @Test + fun `updateHwLimits sources the available amount from the hardware account balance`() = test { + // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + .thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = ON_CHAIN_BALANCE, + ), + ), + ) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateHwLimits(DEVICE_ID) + advanceUntilIdle() + + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) + } + + @Test + fun `updateHwLimits reserves fallback fee when fee rate lookup fails`() = test { + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(DEVICE_ID)) + .thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = ON_CHAIN_BALANCE, + ), + ), + ) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("fee unavailable"))) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateHwLimits(DEVICE_ID) + advanceUntilIdle() + + val fallbackReserve = (ON_CHAIN_BALANCE.toDouble() * Defaults.fallbackFeePercent).toULong() + verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE.safe() - fallbackReserve.safe()), any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).composeFundingTransaction( + eq(DEVICE_ID), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(FEE_RATE), + ) + verify(hwWalletRepo).signAndBroadcastFunding(eq(DEVICE_ID), eq(funding)) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + verify(transferRepo).createTransfer( + eq(TransferType.TO_SPENDING), + eq(order.clientBalanceSat.toLong()), + isNull(), + eq(TXID), + eq(order.id), + isNull(), + ) + verify(transferRepo).createPendingToSpendingActivity( + eq(order), + eq(TXID), + eq(MINING_FEE), + eq(FEE_RATE), + ) + verify(hwWalletRepo).ensureConnected(DEVICE_ID) + } + + @Test + fun `onTransferToSpendingHwConfirm composes with fallback fee rate when fee lookup fails`() = test { + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FALLBACK_FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FALLBACK_FEE_RATE, + ) + val broadcast = HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FALLBACK_FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())) + .thenReturn(Result.failure(AppError("fee unavailable"))) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.success(broadcast)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).composeFundingTransaction( + eq(DEVICE_ID), + eq(order.payment?.onchain?.address.orEmpty()), + eq(order.feeSat), + eq(FALLBACK_FEE_RATE), + ) + } + + @Test + fun `onTransferToSpendingHwConfirm aborts when hardware reconnect fails`() = test { + val order = previewBtOrder() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = false)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.failure(RuntimeException("no device"))) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).ensureConnected(DEVICE_ID) + verify(hwWalletRepo, never()).composeFundingTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signAndBroadcastFunding(any(), any()) + } + + @Test + fun `onTransferToSpendingHwConfirm disconnects stale session when signing times out`() = test { + val order = previewBtOrder() + val timeout = runCatching { withTimeout(0) { Unit } }.exceptionOrNull() as TimeoutCancellationException + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(DEVICE_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(DEVICE_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signAndBroadcastFunding(any(), any())).thenReturn(Result.failure(timeout)) + whenever(hwWalletRepo.disconnectStaleSession(DEVICE_ID)).thenReturn(Result.success(Unit)) + + sut.onTransferToSpendingHwConfirm(order, DEVICE_ID) + advanceUntilIdle() + + verify(hwWalletRepo).disconnectStaleSession(DEVICE_ID) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + private fun hwWallet(deviceId: String, connected: Boolean) = HwWallet( + id = deviceId, + name = "Trezor", + model = "Safe 3", + transportType = TransportType.USB, + isConnected = connected, + balanceSats = 0uL, + activities = persistentListOf(), + deviceIds = persistentSetOf(deviceId), + ) + private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, @@ -147,5 +362,11 @@ class TransferViewModelTest : BaseUnitTest() { const val NETWORK_FEE = 2_112uL const val SERVICE_FEE = 286uL const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE + const val DEVICE_ID = "dev1" + const val XPUB = "zpub-test" + const val TXID = "tx-abc" + const val FEE_RATE = 2uL + const val FALLBACK_FEE_RATE = 1uL + const val MINING_FEE = 1_250uL } } diff --git a/changelog.d/next/1039.added.md b/changelog.d/next/1039.added.md new file mode 100644 index 0000000000..37e39e0752 --- /dev/null +++ b/changelog.d/next/1039.added.md @@ -0,0 +1 @@ +Transfer funds from a paired Trezor hardware wallet to your spending balance, with safer fee handling and confirmation tracking for device-signed funding transactions. diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 782a716824..b823c0b83e 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -66,6 +66,9 @@ Remove step forgets the device. | `connect-flow.xml` | Settings Add button → connect flow with an edited Label Funds → paired device count + name | | `settings-hardware-wallets.xml` | Payments count row, Hardware Wallets screen list, Add button sheet/back dismiss, per-row delete confirm + re-pair | | `detail-overview.xml` | Detail screen overview, Transfer placeholder when funded, activity, Remove confirm + forget | +| `transfer-to-spending.xml` | Happy-path transfer amount → sign → processing flow with a valid amount below the cap | +| `transfer-to-spending-max-lsp-cap.xml` | MAX when Trezor balance is higher than remaining LSP headroom; verifies MAX uses AVAILABLE and reaches sign without insufficient funds | +| `transfer-to-spending-node-warmup.xml` | Transfer started during app/node warm-up; verifies loading recovers into the sign screen | Connect-flow testTags: `HardwareWalletSheet`, `HardwareWalletIntroScreen`, `HardwareWalletIntroCancel`, `HardwareWalletIntroContinue`, @@ -94,3 +97,9 @@ To exercise the received-money sheet (not covered by a journey because it needs out-of-band transfer), fund the emulator wallet on regtest from `bitkit-docker`, e.g. send to an address generated via Dev Settings → Trezor → Get Address, then mine a block with `./bitcoin-cli`. + +For transfer-to-spending QA, explicitly cover the LSP cap boundary: the hardware wallet +balance can be much larger than the displayed AVAILABLE amount because MAX is capped by +Blocktank channel headroom. After signing, decode the funding transaction and compare the +activity DB row: the on-chain activity fee should be the composed mining fee, while the +funding output should equal the final Blocktank `order.feeSat`. diff --git a/journeys/hardware-wallet/detail-overview.xml b/journeys/hardware-wallet/detail-overview.xml index e8175b31cf..8d13bda3c6 100644 --- a/journeys/hardware-wallet/detail-overview.xml +++ b/journeys/hardware-wallet/detail-overview.xml @@ -1,8 +1,8 @@ Opens the hardware wallet detail screen from the home tile and verifies its overview: - the top bar, balance header, the current Transfer To Spending placeholder when funds - are present, the grouped activity list, and the Remove device confirm dialog. The final + the top bar, balance header, the Transfer To Spending entry when funds are present, + the grouped activity list, and the Remove device confirm dialog. The final Remove step forgets the device, so run this last (re-run connect-home-tile.xml to pair again). Requires a paired Bridge emulator (run connect-home-tile.xml first). @@ -17,7 +17,7 @@ Verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen"), showing the device name with a blue bitcoin icon in the top bar and a balance header - If a "Transfer To Spending" button is shown (testTag "HwTransferToSpending"), tap it and verify a notice appears saying "Transfer to spending not yet implemented.", otherwise skip this step + If a "Transfer To Spending" button is shown (testTag "HardwareTransferToSpending"), tap it, verify the transfer amount screen opens (testTag "HardwareTransferAmount") titled "TRANSFER TO SPENDING", then navigate back to the hardware wallet detail screen; otherwise skip this step If the activity list shows transactions, verify their circular icons are blue, then tap the first one, verify an activity detail screen opens, and navigate back diff --git a/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml new file mode 100644 index 0000000000..a36e8485d6 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-max-lsp-cap.xml @@ -0,0 +1,44 @@ + + + Verifies that a funded hardware wallet whose balance is larger than the current + Blocktank/LSP headroom is capped by the transferable spending limit, not by the + device balance. Requires a paired Bridge emulator or physical Trezor with a hardware + balance larger than the available transfer limit, and at least one existing channel or + pending order consuming most of the regtest LSP cap. + + + + Launch the Bitkit app and go to the wallet home screen + + + Verify the hardware wallet tile shows a balance larger than the AVAILABLE amount expected in the transfer flow + + + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", and the AVAILABLE amount is lower than the hardware wallet balance because it is capped by LSP headroom + + + Tap the "MAX" quick button (testTag "HardwareTransferAmountMax") + + + Verify the amount field matches the AVAILABLE amount exactly and does not use the full hardware wallet balance + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + + + Verify the sign screen opens (testTag "HardwareTransferSign"), showing NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL, without an insufficient-funds toast + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and approve every hardware-wallet prompt + + + Verify the transaction signed screen appears (testTag "HardwareTransferSigned") and then advances to Processing Payment / setting-up progress + + + diff --git a/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml new file mode 100644 index 0000000000..518131da38 --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending-node-warmup.xml @@ -0,0 +1,37 @@ + + + Verifies that starting a hardware-wallet transfer while the Lightning node is still + warming up does not fail or strand the CTA in loading. The flow should show the normal + loading/progress UI, continue once the node reaches the needed state, and land on the + sign screen. Requires a paired and funded hardware wallet. + + + + adb: adb shell am force-stop to.bitkit.dev + + + adb: adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1 + + + As soon as the wallet home screen is visible, tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen or loading/progress UI appears, and no reconnect, node-not-ready, or generic failure toast is shown while the node warms up + + + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") if the amount screen is shown + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") if the amount screen is shown, then wait for order creation and node warm-up to finish + + + Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", and the Continue/Open Trezor Connect CTA is no longer loading + + + Navigate back without signing so this journey only covers node warm-up behavior + + + diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml new file mode 100644 index 0000000000..4c628a7b4b --- /dev/null +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -0,0 +1,43 @@ + + + Drives the watch-only Transfer To Spending flow for a paired Trezor: Amount -> Sign With + Your Device -> Transaction Signed -> Processing Payment. The funding send is signed on the + Bridge emulator device, so no physical Trezor is required. Requires a paired Bridge emulator + (run connect-home-tile.xml first) whose native-segwit account holds spendable regtest funds, + and the bitkit-docker stack (Blocktank + regtest) running so the channel order can be created + and funded. Mirrors the on-chain Transfer To Spending flow, swapping local signing for the + device. + + + + Launch the Bitkit app and go to the wallet home screen + + + Tap the hardware wallet tile beneath the SAVINGS and SPENDING tiles, and verify the hardware wallet detail screen opens (testTag "HardwareWalletScreen") + + + Tap the "Transfer To Spending" button (testTag "HardwareTransferToSpending") + + + Verify the transfer amount screen opens (testTag "HardwareTransferAmount"), titled "TRANSFER TO SPENDING", showing an AVAILABLE row, the 25% and MAX quick buttons, and a number pad + + + Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") to set a valid amount below the available limit + + + Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + + + Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration + + + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") and wait while the Bridge emulator composes, signs and broadcasts the funding transaction + + + Verify the transaction signed screen appears (testTag "HardwareTransferSigned"), titled "TRANSACTION SIGNED", showing the same fee cells and the checkmark illustration + + + Wait for the screen to auto-forward and verify the Processing Payment / setting-up progress screen appears + + +