Skip to content
Draft
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
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
}
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

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

private val lazyDefault: String by lazy { getDefaultWalletId() }
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 lazyDefault is eagerly consumed in ActivityService's constructor. ActivityService stores private val walletId = WalletScope.default, triggering getDefaultWalletId() at construction time. Kotlin's SYNCHRONIZED lazy does not cache thrown exceptions, so a premature call would fail DI resolution rather than cache a wrong ID. Worth confirming that CoreService/ActivityService is never constructed before the core is initialised — tests use testOverride to sidestep this, but production relies on DI graph ordering.

}
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
109 changes: 37 additions & 72 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 @@ -107,6 +106,8 @@ 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)
Expand Down Expand Up @@ -153,6 +154,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 +228,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 +358,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)
}
}
}
Expand All @@ -378,21 +381,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))
}
}

Expand Down Expand Up @@ -421,7 +424,13 @@ class HwWalletRepo @Inject constructor(
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 = device.walletId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate wallet ids before starting hardware watchers

For users upgrading with already-paired hardware wallets, KnownDevice.walletId deserializes as the new default "" until TrezorRepo.loadKnownDevices() migrates the store during startWatcher(). This spec is built from the raw HwWalletStore value before that migration, so the first watcher can be started with a blank wallet scope; when the migrated store emits, the watcher is considered active because only the Electrum URL is compared, so it is not restarted with the derived wallet id. That leaves upgraded devices running in the wrong/empty activity scope for the session; derive/fallback the id here or include walletId in the active watcher config and restart when it changes.

Useful? React with 👍 / 👎.

)
}
}.distinctBy { it.addressType to it.xpub }
val filteredIds = filtered.map { it.watcherId }.toSet()
Expand All @@ -437,6 +446,7 @@ 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
Expand Down Expand Up @@ -473,59 +483,14 @@ 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 List<HwWatcherData>.toMergedActivities(): List<Activity> {
val sourceActivities = flatMap { it.activities }
return flatMap { it.transactions }
.groupBy { it.txid }
.values
.map { transactions ->
val timestamp = transactions.mapNotNull { it.timestamp }.minOrNull()
?: sourceActivities.findOnchain(transactions.first().txid)?.v1?.timestamp
?: 0uL
transactions.toOnchainActivity(timestamp, sourceActivities)
val byId = linkedMapOf<String, Activity>()
sortedBy { "${it.deviceId}|${it.addressType}" }
.flatMap { it.activities }
.forEach { activity ->
byId[activity.rawId()] = activity

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge duplicate watcher activities by amount

For transactions that touch two monitored xpubs for the same hardware wallet, such as receiving to both native SegWit and Taproot addresses, each address-type watcher can report an Activity.Onchain with the same txid and only that watcher's value. This assignment keeps whichever activity sorts last and drops the other value, so the wallet activity list undercounts while the balance still sums both watchers; keep the previous txid-level merge/summing behavior instead of overwriting by rawId().

Useful? React with 👍 / 👎.

}
}

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 {
received > sent -> PaymentType.RECEIVED
else -> PaymentType.SENT
}
val value = when (type) {
PaymentType.RECEIVED -> received.safe() - sent.safe()
PaymentType.SENT -> (sent.safe() - received.safe()).safe() - fee.safe()
}
val confirmations = maxOf { it.confirmations }
val sourceActivity = sourceActivities.findOnchain(first.txid)
return Activity.Onchain(
OnchainActivity.create(
id = first.txid,
txType = type,
txId = first.txid,
value = value,
fee = fee,
address = "",
timestamp = timestamp,
confirmed = confirmations > 0u,
confirmTimestamp = sourceActivity?.v1?.confirmTimestamp,
)
)
return byId.values.toList()
}

private fun List<Activity>.findOnchain(txid: String) = filterIsInstance<Activity.Onchain>()
Expand All @@ -536,6 +501,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 +543,5 @@ private data class HwWatcherData(
val deviceId: String,
val addressType: String,
val balanceSats: ULong,
val transactions: ImmutableList<HistoryTransaction>,
val activities: ImmutableList<Activity>,
)
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import to.bitkit.services.LnurlWithdrawResponse
import to.bitkit.services.LspNotificationsService
import to.bitkit.services.NodeEventHandler
import to.bitkit.utils.AppError
import to.bitkit.models.WalletScope
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
import to.bitkit.utils.UrlValidator
Expand Down Expand Up @@ -1178,6 +1179,7 @@ class LightningRepo @Inject constructor(
val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount)

val preActivityMetadata = PreActivityMetadata(
walletId = WalletScope.default,
paymentId = txId,
createdAt = nowTimestamp().toEpochMilli().toULong(),
tags = tags,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import to.bitkit.di.IoDispatcher
import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
import to.bitkit.services.CoreService
import to.bitkit.models.WalletScope
import to.bitkit.utils.Logger
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -127,11 +128,13 @@ class PreActivityMetadataRepo @Inject constructor(
feeRate: ULong? = null,
isTransfer: Boolean = false,
channelId: String? = null,
walletId: String = WalletScope.default,
): Result<Unit> = withContext(ioDispatcher) {
return@withContext runCatching {
require(tags.isNotEmpty() || isTransfer)

val preActivityMetadata = PreActivityMetadata(
walletId = walletId,
paymentId = id,
createdAt = nowTimestamp().toEpochMilli().toULong(),
tags = tags,
Expand Down
Loading
Loading