Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 76 additions & 7 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -30,6 +34,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand Down Expand Up @@ -103,6 +108,7 @@ class HwWalletRepo @Inject constructor(
private const val WATCHER_ID_SEPARATOR = "|"
private val WATCHER_START_RETRY_DELAY = 30.seconds
const val DEVICE_LABEL_MAX_LENGTH = 50
private const val MAX_FEE_ESTIMATES = 32

/** Trezor v1 (2.4.0) tracks native SegWit accounts. */
private val SUPPORTED_WATCHER_ADDRESS_TYPES = setOf(HwFundingAddressType.NATIVE_SEGWIT.settingsKey)
Expand All @@ -116,6 +122,24 @@ class HwWalletRepo @Inject constructor(
private var scanBluetoothVendor = HwWalletVendor.TREZOR

private val scope = appScope(ioDispatcher, TAG)

/**
* Exact-amount fee composes, shared by every caller asking for the same payment. A compose runs
* on the single core queue and takes seconds, so choosing a funding source, the confirm screen
* and a refresh that replaces a cancelled one all reuse one result. Keyed by the funding
* account's state (balance and transactions), so any change to its coins composes again; a
* failed compose is not kept.
*/
private val feeEstimates = mutableMapOf<FeeEstimateKey, Deferred<Result<ULong>>>()
private val feeEstimatesLock = Any()

private data class FeeEstimateKey(
val walletId: String,
val address: String,
val sats: ULong,
val satsPerVByte: ULong,
val fundingState: Int,
)
private val watcherMutex = Mutex()
private val sessionMutex = Mutex()

Expand Down Expand Up @@ -754,14 +778,59 @@ class HwWalletRepo @Inject constructor(
address: String,
sats: ULong,
satsPerVByte: ULong,
): Result<ULong> = withContext(ioDispatcher) {
runSuspendCatching {
composeFundingOffline(
walletId = walletId,
output = ComposeOutput.Payment(address = address, amountSats = sats),
satsPerVByte = satsPerVByte,
).fee
): Result<ULong> {
val key = FeeEstimateKey(
walletId = walletId,
address = address,
sats = sats,
satsPerVByte = satsPerVByte,
fundingState = fundingState(walletId),
)
val estimate = synchronized(feeEstimatesLock) {
feeEstimates.keys.removeAll { it.walletId == walletId && it.fundingState != key.fundingState }
if (feeEstimates.size >= MAX_FEE_ESTIMATES && key !in feeEstimates) feeEstimates.clear()
feeEstimates[key] ?: run {
// Owned by the repository scope, so a caller that is cancelled mid-compose leaves
// the result for the next caller instead of discarding it.
// Started only once stored, so a compose that ends at once can still drop itself.
val created = scope.async(ioDispatcher, start = CoroutineStart.LAZY) {
runSuspendCatching {
composeFundingOffline(
walletId = walletId,
output = ComposeOutput.Payment(address = address, amountSats = sats),
satsPerVByte = satsPerVByte,
).fee
}.also { result ->
// Dropped by the compose itself, not by a waiting caller, which may be gone.
if (result.isFailure) forgetFeeEstimate(key, coroutineContext.job)
}
}
feeEstimates[key] = created
created.invokeOnCompletion { cause -> if (cause != null) forgetFeeEstimate(key, created) }
created.start()
created
}
}
return estimate.await()
}

private fun forgetFeeEstimate(key: FeeEstimateKey, estimate: Job) = synchronized(feeEstimatesLock) {
if (feeEstimates[key] === estimate) feeEstimates.remove(key)
}

/**
* Changes whenever the funding account's coins can have changed: any transaction added, replaced
* or dropped, or a balance change. The balance alone can return to the same total over other coins.
*/
private fun fundingState(walletId: String): Int {
val funding = _watcherData.value.values.filter {
it.walletId == walletId && it.addressType == HwFundingAddressType.DEFAULT
}
val balance = funding.fold(0uL) { acc, watcher -> acc + watcher.balanceSats }
val txIds = funding.flatMap { watcher ->
watcher.activities.filterIsInstance<Activity.Onchain>().map { it.v1.txId }
}.sorted()
return (listOf(balance.toString()) + txIds).hashCode()
}

/** Exact amount available after the coin-selection fee, computed offline from the account xpub. */
Expand Down
66 changes: 60 additions & 6 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import to.bitkit.ext.watchUntil
import to.bitkit.flags.PaykitFeatureFlags
import to.bitkit.models.FeeRate
import to.bitkit.models.HwFundingSignedTx
import to.bitkit.models.HwWallet
import to.bitkit.models.HwWalletVendor
import to.bitkit.models.NewTransactionSheetDetails
import to.bitkit.models.NewTransactionSheetDirection
Expand Down Expand Up @@ -4219,8 +4220,10 @@ class AppViewModel @Inject constructor(

else -> false
}
// A scanned amount is the payee's, even when it equals the hardware maximum.
val wasHardwareMax = previous.hardwareWalletId != null && previous.fixedOnchainAmount == null &&
// A scanned amount or a payment request's amount is the payee's, so it never follows the
// send-max amount, even when it equals what the wallet can spend.
val wasHardwareMax = activeIncomingPaymentRequest() == null &&
previous.hardwareWalletId != null && previous.fixedOnchainAmount == null &&
previous.amount > 0uL &&
previous.amount == previous.hardwareAvailableSats
val hardwareAvailableSats = previous.hardwareWalletId?.let { walletId ->
Expand Down Expand Up @@ -4465,8 +4468,8 @@ class AppViewModel @Inject constructor(
walletGeneration: Long? = connectionRecovery.currentGeneration(),
): Boolean {
val state = _sendUiState.value
for (wallet in hwWalletRepo.wallets.value) {
val available = hardwareMaxSpendable(wallet.id, state.address, state.speed, walletGeneration)
for (wallet in hwWalletRepo.wallets.value.sortedByDescending { it.fundingBalanceSats }) {
val available = hardwareAvailableForPayment(wallet, amount, state, walletGeneration)
if (amount > available) continue
_sendUiState.update {
it.copy(
Expand All @@ -4479,13 +4482,39 @@ class AppViewModel @Inject constructor(
confirmedWarnings = persistentListOf(),
)
}
// The caller refreshes the confirm fee once it navigates; refreshing here too would
// start a refresh that the caller's then cancels.
updateCanSwitchWallet()
refreshOnchainSendIfNeeded(walletGeneration)
return true
}
return false
}

/**
* What [wallet] can spend toward [amount]. Composing the exact payment proves it can pay and
* leaves its fee in [HwWalletRepo] for the confirm screen, so the request is composed once.
* When that fails, the max-spendable compose says how far short the wallet is.
*/
private suspend fun hardwareAvailableForPayment(
wallet: HwWallet,
amount: ULong,
state: SendUiState,
walletGeneration: Long?,
): ULong {
val satsPerVByte = state.feeRates?.getSatsPerVByteFor(state.speed) ?: 0u
if (wallet.fundingBalanceSats >= amount && satsPerVByte > 0u) {
requireCurrentPaymentPreparation(walletGeneration)
val fee = hwWalletRepo.estimateFundingMiningFee(
walletId = wallet.id,
address = state.address,
sats = amount,
satsPerVByte = satsPerVByte.toULong(),
).also { requireCurrentPaymentPreparation(walletGeneration) }.getOrNull()
if (fee != null) return wallet.fundingBalanceSats.safe() - fee.safe()
}
return hardwareMaxSpendable(wallet.id, state.address, state.speed, walletGeneration)
}

fun switchToLightning() {
viewModelScope.launch {
sendStateGeneration++
Expand Down Expand Up @@ -7195,7 +7224,9 @@ class AppViewModel @Inject constructor(
}
requireCurrentPaymentPreparation(walletGeneration)
if (generation != sendStateGeneration) return@launchOnchainFeeRefresh
refreshOnchainFeeUi(walletGeneration)
// Each hardware fee is a compose of several seconds on the single core queue; signing
// would wait behind the other speeds, so they are estimated when the picker opens.
refreshOnchainFeeUi(walletGeneration, selectedSpeedOnly = currentState.hardwareWalletId != null)
}
}

Expand Down Expand Up @@ -7236,15 +7267,38 @@ class AppViewModel @Inject constructor(
return job
}

/**
* With [selectedSpeedOnly], only the selected speed's fee is estimated and `estimates` is left
* empty, so opening the speed picker estimates the rest ([SendEvent.SpeedAndFee]).
*/
private suspend fun refreshOnchainFeeUi(
walletGeneration: Long? = connectionRecovery.currentGeneration(),
selectedSpeedOnly: Boolean = false,
) = withContext(bgDispatcher) {
requireCurrentPaymentPreparation(walletGeneration)
val generation = sendStateGeneration
val currentState = _sendUiState.value
if (currentState.isRetainedHardwarePayment) return@withContext
updateOnchainFeeUi { it.copy(isLoading = true) }

if (selectedSpeedOnly) {
val fee = if (currentState.feeRates?.getSatsPerVByteFor(currentState.speed) != 0u) {
getFeeEstimate(currentState.speed, walletGeneration)
} else {
0
}
requireCurrentPaymentPreparation(walletGeneration)
if (generation != sendStateGeneration) return@withContext
updateOnchainFeeUi {
OnchainFeeUi(
rate = FeeRate.fromSpeed(currentState.speed),
sats = fee.takeIf { sats -> sats > 0 },
isLoading = false,
)
}
return@withContext
}

val speeds = listOf(
TransactionSpeed.Fast,
TransactionSpeed.Medium,
Expand Down
Loading
Loading