Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.LightningActivity
import com.synonym.bitkitcore.OnchainActivity
import com.synonym.bitkitcore.PaymentState
import com.synonym.bitkitcore.PaymentType
import to.bitkit.models.WalletScope

fun Activity.rawId(): String = when (this) {
is Activity.Lightning -> v1.id
Expand Down Expand Up @@ -94,6 +95,7 @@ enum class BoostType { RBF, CPFP }

@Suppress("LongParameterList")
fun LightningActivity.Companion.create(
walletId: String = WalletScope.default,
id: String,
txType: PaymentType,
status: PaymentState,
Expand All @@ -108,6 +110,7 @@ fun LightningActivity.Companion.create(
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
) = LightningActivity(
walletId = walletId,
id = id,
txType = txType,
status = status,
Expand All @@ -125,6 +128,7 @@ fun LightningActivity.Companion.create(

@Suppress("LongParameterList")
fun OnchainActivity.Companion.create(
walletId: String = WalletScope.default,
id: String,
txType: PaymentType,
txId: String,
Expand All @@ -146,6 +150,7 @@ fun OnchainActivity.Companion.create(
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
) = OnchainActivity(
walletId = walletId,
id = id,
txType = txType,
txId = txId,
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/ext/TrezorExceptionExt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package to.bitkit.ext

import com.synonym.bitkitcore.TrezorException

fun Throwable.isTrezorUserCancellation(): Boolean {
var current: Throwable? = this
while (current != null) {
when (current) {
is TrezorException.UserCancelled,
is TrezorException.PinCancelled,
is TrezorException.PassphraseCancelled,
-> return true
}
current = current.cause
}
return false
}
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/models/HwWalletId.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package to.bitkit.models

import com.synonym.bitkitcore.deriveWalletId

object HwWalletId {
fun derive(xpubs: Map<String, String>, deviceType: String = "trezor"): String {
require(xpubs.isNotEmpty()) { "xpubs must not be empty" }
return deriveWalletId(deviceType = deviceType, xpubs = xpubs.values.toList())
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/to/bitkit/models/WalletScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package to.bitkit.models

import androidx.annotation.VisibleForTesting
import com.synonym.bitkitcore.getDefaultWalletId

object WalletScope {
@VisibleForTesting
internal var testOverride: String? = null
Comment thread
jvsena42 marked this conversation as resolved.
Outdated

val default: String
get() = testOverride ?: lazyDefault

private val lazyDefault: String by lazy { getDefaultWalletId() }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.contact
import to.bitkit.ext.create
import to.bitkit.ext.isReplacedSentTransaction
import to.bitkit.ext.matchesPaymentId
import to.bitkit.ext.nowMillis
Expand Down Expand Up @@ -653,7 +654,7 @@ class ActivityRepo @Inject constructor(
val now = nowTimestamp().epochSecond.toULong()
insertActivity(
Activity.Lightning(
LightningActivity(
LightningActivity.create(
id = id,
txType = PaymentType.RECEIVED,
status = PaymentState.SUCCEEDED,
Expand Down
147 changes: 84 additions & 63 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ 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
import com.synonym.bitkitcore.TrezorDeviceInfo
import com.synonym.bitkitcore.TrezorFeatures
import com.synonym.bitkitcore.TxDirection
import com.synonym.bitkitcore.WatcherEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
Expand Down Expand Up @@ -38,6 +36,7 @@ import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.create
import to.bitkit.ext.isTrezorUserCancellation
import to.bitkit.ext.rawId
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.HwFundingAccount
Expand Down Expand Up @@ -92,6 +91,7 @@ class HwWalletRepo @Inject constructor(

private val activeWatchers = mutableSetOf<String>()
private val activeWatcherElectrumUrls = mutableMapOf<String, String>()
private val activeWatcherWalletIds = mutableMapOf<String, String>()
private val retryingWatcherStarts = mutableSetOf<String>()
private val watcherSyncRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _watcherData = MutableStateFlow<Map<String, HwWatcherData>>(emptyMap())
Expand All @@ -107,13 +107,16 @@ class HwWalletRepo @Inject constructor(

fun onAppForegrounded() = trezorRepo.onAppForegrounded()

fun warmUpKnownDevice(deviceId: String) = trezorRepo.warmUpKnownDevice(deviceId)

suspend fun resetState() = withContext(ioDispatcher) {
activeWatchers.toList().forEach { watcherId ->
trezorRepo.stopWatcher(watcherId)
.onFailure { Logger.warn("Failed to stop watcher '$watcherId' while resetting", it, context = TAG) }
}
activeWatchers.clear()
activeWatcherElectrumUrls.clear()
activeWatcherWalletIds.clear()
retryingWatcherStarts.clear()
emittedReceivedTxIds.clear()
_watcherData.update { emptyMap() }
Expand Down Expand Up @@ -153,6 +156,8 @@ class HwWalletRepo @Inject constructor(

suspend fun ensureConnected(deviceId: String): Result<TrezorFeatures> = trezorRepo.ensureConnected(deviceId)

suspend fun isKnownBluetoothDevice(deviceId: String): Boolean = trezorRepo.isKnownBluetoothDevice(deviceId)

suspend fun getFundingAccount(
deviceId: String,
addressType: HwFundingAddressType = HwFundingAddressType.DEFAULT,
Expand Down Expand Up @@ -225,7 +230,10 @@ class HwWalletRepo @Inject constructor(
).getOrThrow()
}
if (signed.isFailure) {
trezorRepo.disconnectStaleSession(deviceId)
val failure = signed.exceptionOrNull()
if (failure?.isTrezorUserCancellation() != true) {
trezorRepo.disconnectStaleSession(deviceId)
}
}
val txId = trezorRepo.broadcastRawTx(serializedTx = signed.getOrThrow().serializedTx).getOrThrow()
HwFundingBroadcastResult(
Expand Down Expand Up @@ -352,22 +360,19 @@ class HwWalletRepo @Inject constructor(
trezorRepo.watcherEvents.collect { (watcherId, event) ->
if (event !is WatcherEvent.TransactionsChanged) return@collect
val previous = _watcherData.value[watcherId]
val activities = event.transactions
.map { it.toOnchainActivity(clock, previous?.activities.orEmpty()) }
.toImmutableList()
val activities = event.activities.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<Activity.Onchain>().forEach {
activityRepo.syncHardwareOnchainActivity(it.v1)
}
emitReceivedTxs(previous, event, updatedWatcherData)
emitReceivedTxs(previous, activities, updatedWatcherData)
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}
}
}
Expand All @@ -378,21 +383,21 @@ class HwWalletRepo @Inject constructor(
*/
private suspend fun emitReceivedTxs(
previous: HwWatcherData?,
event: WatcherEvent.TransactionsChanged,
activities: List<Activity>,
watcherData: Map<String, HwWatcherData>,
) {
if (previous == null) return
val knownTxIds = previous.activities.map { it.rawId() }.toSet()
val knownTxIds = previous.activities.mapNotNull { activity ->
(activity as? Activity.Onchain)?.v1?.txId
}.toSet()
val mergedActivities = watcherData.values.toList().toMergedActivities()
event.transactions
.filter {
it.direction == TxDirection.RECEIVED &&
it.txid !in knownTxIds &&
emittedReceivedTxIds.add(it.txid)
}
.forEach {
val sats = mergedActivities.findOnchain(it.txid)?.v1?.value ?: it.amount
_receivedTxs.emit(HwWalletReceivedTx(txid = it.txid, sats = sats))
activities.filterIsInstance<Activity.Onchain>()
.filter { it.v1.txType == PaymentType.RECEIVED }
.forEach { onchain ->
val txid = onchain.v1.txId
if (txid in knownTxIds || !emittedReceivedTxIds.add(txid)) return@forEach
val sats = mergedActivities.findOnchain(txid)?.v1?.value ?: onchain.v1.value
_receivedTxs.emit(HwWalletReceivedTx(txid = txid, sats = sats))
Comment thread
jvsena42 marked this conversation as resolved.
}
}

Expand All @@ -418,17 +423,30 @@ class HwWalletRepo @Inject constructor(
// toggling a type on later starts its watcher without reconnecting the device.
// Device entries sharing an xpub (same device on bluetooth and usb) watch it only once.
val filtered = knownDevices.flatMap { device ->
val walletId = device.resolvedWalletId()
device.xpubs
.filterKeys { it in watcherSettings.monitoredTypes }
.map { (addressType, xpub) ->
WatcherSpec(device.id, addressType, xpub, watcherSettings.electrumUrl)
WatcherSpec(
deviceId = device.id,
addressType = addressType,
xpub = xpub,
electrumUrl = watcherSettings.electrumUrl,
walletId = walletId,
)
}
}.distinctBy { it.addressType to it.xpub }
val filteredIds = filtered.map { it.watcherId }.toSet()

filtered.forEach { spec ->
val isActive = spec.watcherId in activeWatchers
if (isActive && activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl) return@forEach
if (
isActive &&
activeWatcherElectrumUrls[spec.watcherId] == spec.electrumUrl &&
activeWatcherWalletIds[spec.watcherId] == spec.walletId
) {
return@forEach
}
if (isActive && !stopActiveWatcher(spec.watcherId)) return@forEach

trezorRepo.startWatcher(
Expand All @@ -437,9 +455,11 @@ class HwWalletRepo @Inject constructor(
network = Env.network.toCoreNetwork(),
accountType = spec.addressType.toAddressType()?.toAccountType(),
electrumUrl = spec.electrumUrl,
walletId = spec.walletId,
).onSuccess {
activeWatchers += spec.watcherId
activeWatcherElectrumUrls[spec.watcherId] = spec.electrumUrl
activeWatcherWalletIds[spec.watcherId] = spec.walletId
retryingWatcherStarts -= spec.watcherId
}.onFailure {
Logger.warn("Retrying watcher '${spec.watcherId}' after start failure", it, context = TAG)
Expand All @@ -460,6 +480,7 @@ class HwWalletRepo @Inject constructor(
trezorRepo.stopWatcher(watcherId).onSuccess {
activeWatchers -= watcherId
activeWatcherElectrumUrls -= watcherId
activeWatcherWalletIds -= watcherId
_watcherData.update { it - watcherId }
}.isSuccess

Expand All @@ -473,57 +494,57 @@ class HwWalletRepo @Inject constructor(
}
}

private fun HistoryTransaction.toOnchainActivity(clock: Clock, previousActivities: List<Activity>): Activity {
val activityTimestamp = timestamp ?: previousActivities.findOnchain(txid)?.v1?.timestamp
?: clock.now().epochSeconds.toULong()
return listOf(this).toOnchainActivity(
timestamp = activityTimestamp,
sourceActivities = previousActivities,
)
}
private fun KnownDevice.resolvedWalletId(): String =
walletId.takeIf { it.isNotBlank() } ?: trezorRepo.deriveWalletId(xpubs, id)

private fun List<HwWatcherData>.toMergedActivities(): List<Activity> {
val sourceActivities = flatMap { it.activities }
return flatMap { it.transactions }
.groupBy { it.txid }
private fun List<HwWatcherData>.toMergedActivities(): List<Activity> =
flatMap { it.activities }
.groupBy { it.rawId() }
.values
.map { transactions ->
val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull()
?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp
?: 0uL
transactions.toOnchainActivity(timestamp, sourceActivities)
}
}

private fun List<HistoryTransaction>.toOnchainActivity(
timestamp: ULong,
sourceActivities: List<Activity>,
): Activity {
val first = first()
val received = fold(0uL) { acc, tx -> acc.safe() + tx.received.safe() }
val sent = fold(0uL) { acc, tx -> acc.safe() + tx.sent.safe() }
val fee = mapNotNull { it.fee }.maxOrNull() ?: 0uL
val type = when {
.map { it.mergedActivity() }

private fun List<Activity>.mergedActivity(): Activity {
if (size == 1) return first()

val onchainActivities = filterIsInstance<Activity.Onchain>()
if (onchainActivities.size != size) return first()

val base = onchainActivities.minBy { it.v1.timestamp }
val received = onchainActivities.filter { it.v1.txType == PaymentType.RECEIVED }
.fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() }
val sent = onchainActivities.filter { it.v1.txType == PaymentType.SENT }
.fold(0uL) { acc, activity -> acc.safe() + activity.v1.value.safe() }
val fee = onchainActivities.maxOf { it.v1.fee }
val feeRate = onchainActivities.maxOf { it.v1.feeRate }
val txType = when {
received > sent -> PaymentType.RECEIVED
else -> PaymentType.SENT
sent > received -> PaymentType.SENT
else -> base.v1.txType
}
val value = when (type) {
val value = when (txType) {
PaymentType.RECEIVED -> received.safe() - sent.safe()
PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe()
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}
val confirmations = maxOf { it.confirmations }
val sourceActivity = sourceActivities.findOnchain(first.txid)

return Activity.Onchain(
OnchainActivity.create(
id = first.txid,
txType = type,
txId = first.txid,
base.v1.copy(
txType = txType,
value = value,
fee = fee,
address = "",
timestamp = timestamp,
confirmed = confirmations > 0u,
confirmTimestamp = sourceActivity?.v1?.confirmTimestamp,
feeRate = feeRate,
address = onchainActivities.firstOrNull { it.v1.address.isNotBlank() }?.v1?.address.orEmpty(),
confirmed = onchainActivities.any { it.v1.confirmed },
isBoosted = onchainActivities.any { it.v1.isBoosted },
boostTxIds = onchainActivities.flatMap { it.v1.boostTxIds }.distinct(),
isTransfer = onchainActivities.any { it.v1.isTransfer },
doesExist = onchainActivities.any { it.v1.doesExist },
confirmTimestamp = onchainActivities.mapNotNull { it.v1.confirmTimestamp }.maxOrNull(),
channelId = onchainActivities.firstNotNullOfOrNull { it.v1.channelId },
transferTxId = onchainActivities.firstNotNullOfOrNull { it.v1.transferTxId },
contact = onchainActivities.firstNotNullOfOrNull { it.v1.contact },
createdAt = onchainActivities.mapNotNull { it.v1.createdAt }.minOrNull(),
updatedAt = onchainActivities.mapNotNull { it.v1.updatedAt }.maxOrNull(),
seenAt = onchainActivities.mapNotNull { it.v1.seenAt }.minOrNull(),
)
)
}
Expand All @@ -536,6 +557,7 @@ class HwWalletRepo @Inject constructor(
val addressType: String,
val xpub: String,
val electrumUrl: String,
val walletId: String,
) {
val watcherId: String get() = "$deviceId$WATCHER_ID_SEPARATOR$addressType"
}
Expand Down Expand Up @@ -577,6 +599,5 @@ private data class HwWatcherData(
val deviceId: String,
val addressType: String,
val balanceSats: ULong,
val transactions: ImmutableList<HistoryTransaction>,
val activities: ImmutableList<Activity>,
)
Loading
Loading