Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
82 changes: 80 additions & 2 deletions app/src/main/java/to/bitkit/data/HwWalletStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,54 @@ class HwWalletStore @Inject constructor(
store.data.first().knownDevices
}

suspend fun saveKnownDevices(devices: List<KnownDevice>) = withContext(ioDispatcher) {
store.updateData { it.copy(knownDevices = devices) }
/**
* @param pendingName a pending-name change to apply in the same write, or null to leave them alone.
* Splitting the two would publish a device list without its matching name change, which restarts a
* watcher for a wallet already being removed and can leave a name in both places or in neither.
*/
suspend fun saveKnownDevices(
devices: List<KnownDevice>,
pendingName: PendingNameUpdate? = null,
) = withContext(ioDispatcher) {
store.updateData { data ->
data.copy(
knownDevices = devices,
pendingNames = pendingName?.applyTo(data.pendingNames) ?: data.pendingNames,
)
}
Unit
}

suspend fun loadPendingNames(): Map<String, String> = withContext(ioDispatcher) {
store.data.first().pendingNames
}

/** Stores the name of a wallet with no device entry, or drops it when [name] is null. */
suspend fun setPendingName(walletId: String, name: String?) = withContext(ioDispatcher) {
if (walletId.isBlank()) return@withContext
store.updateData { data ->
val pendingNames = when {
name.isNullOrBlank() -> data.pendingNames - walletId
else -> data.pendingNames + (walletId to name)
}
data.copy(pendingNames = pendingNames)
}
Unit
}

suspend fun backupSnapshot(): Map<String, String> = withContext(ioDispatcher) {
store.data.first().hwWalletNames()
}

/**
* Merges backed up names into the pending ones, so they are adopted the next time each wallet is
* paired. Names already held locally win: they were set on this device after the backup was written.
*
* Never clears: an envelope without names predates this field, and must not drop what is stored.
*/
suspend fun restoreNames(names: Map<String, String>) = withContext(ioDispatcher) {
if (names.isEmpty()) return@withContext
store.updateData { it.copy(pendingNames = names + it.pendingNames) }
Unit
}

Expand All @@ -44,7 +90,39 @@ class HwWalletStore @Inject constructor(
}
}

/** A pending-name change applied together with a device list write; a null [name] drops the entry. */
data class PendingNameUpdate(
val walletId: String,
val name: String?,
) {
fun applyTo(pendingNames: Map<String, String>): Map<String, String> = when {
walletId.isBlank() -> pendingNames
name.isNullOrBlank() -> pendingNames - walletId
else -> pendingNames + (walletId to name)
}
}

@Serializable
data class HwWalletData(
val knownDevices: List<KnownDevice> = emptyList(),
/**
* Names of wallets with no [KnownDevice] entry, keyed by wallet id: restored from a backup before
* the device was paired again, or kept when the wallet was removed. Pairing consumes an entry into
* the device's own `customLabel`.
*/
val pendingNames: Map<String, String> = emptyMap(),
)

/**
* Every hardware wallet name this wallet knows, keyed by wallet id: the name of each paired wallet,
* overlaid on the pending ones. A paired name wins because it is what the user currently sees.
*
* Entries without a wallet id are skipped. Only a device stored before any account key was captured
* has none, and such an entry is filtered out of the wallet list, so it can never have been named.
*/
internal fun HwWalletData.hwWalletNames(): Map<String, String> =
pendingNames + knownDevices.mapNotNull { device ->
val walletId = device.walletId.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val name = device.customLabel?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
walletId to name
}
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ data class MetadataBackupV1(
val cache: AppCacheData,
val pubkySession: PubkySessionBackupV1? = null,
val pubkyContactProfileOverrides: Map<String, PubkyProfileData>? = null,
/** User-set hardware wallet names, keyed by wallet id. Null in envelopes written before this field. */
val hwWalletNames: Map<String, String>? = null,
)

@Serializable
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,27 @@ class ActivityRepo @Inject constructor(
}
}

/**
* The slice of the metadata backup's tag data that belongs to [walletId], built the same way the
* envelope builds it so a caller can preserve a wallet's tags across a deletion.
*
* Both sources are needed: Core drops the stored [PreActivityMetadata] of a wallet along with its
* activities, and those rows are not covered by [getHardwareTagsAsPreActivityMetadata], which only
* renders tags that already reached an activity.
*/
suspend fun getTagMetadataForWallet(walletId: String): Result<List<PreActivityMetadata>> =
withContext(bgDispatcher) {
runSuspendCatching {
val stored = coreService.activity.getAllPreActivityMetadata()
.filter { it.walletId == walletId }
val rendered = getHardwareTagsAsPreActivityMetadata().getOrThrow()
.filter { it.walletId == walletId }
(stored + rendered).distinctBy { it.walletId to it.paymentId }
}.onFailure {
Logger.error("Failed to read tag metadata of '$walletId'", it, context = TAG)
}
}

/**
* Fill in wallet ids missing from a backup envelope's `activities` slice, letting Core migrate its own
* model JSON before the app decodes it.
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ import to.bitkit.R
import to.bitkit.async.appScope
import to.bitkit.data.AppDb
import to.bitkit.data.CacheStore
import to.bitkit.data.HwWalletStore
import to.bitkit.data.SettingsStore
import to.bitkit.data.WatchOnlyAccountStore
import to.bitkit.data.WidgetsStore
import to.bitkit.data.backup.VssBackupClient
import to.bitkit.data.backup.VssBackupClientLdk
import to.bitkit.data.hwWalletNames
import to.bitkit.data.resetPin
import to.bitkit.di.IoDispatcher
import to.bitkit.di.json
Expand Down Expand Up @@ -91,6 +93,7 @@ class BackupRepo @Inject constructor(
private val widgetsStore: WidgetsStore,
private val watchOnlyAccountStore: WatchOnlyAccountStore,
private val watchOnlyAccountRepo: WatchOnlyAccountRepo,
private val hwWalletStore: HwWalletStore,
private val blocktankRepo: BlocktankRepo,
private val activityRepo: ActivityRepo,
private val pubkyRepo: PubkyRepo,
Expand Down Expand Up @@ -298,6 +301,20 @@ class BackupRepo @Inject constructor(
}
dataListenerJobs.add(preActivityMetadataJob)

// METADATA - Observe hardware wallet names only: the store is also rewritten by every connect,
// and reconnect traffic must not re-upload the whole metadata envelope.
val hwWalletNamesJob = scope.launch {
hwWalletStore.data
.map { it.hwWalletNames() }
.distinctUntilChanged()
.drop(1)
.collect {
if (shouldSkipBackup()) return@collect
markBackupRequired(BackupCategory.METADATA)
}
}
dataListenerJobs.add(hwWalletNamesJob)

dataListenerJobs.add(observeBackupChanges(pubkyRepo.backupStateVersion, BackupCategory.METADATA))
dataListenerJobs.add(observeBackupChanges(privatePaykitRepo.get().backupStateVersion, BackupCategory.WALLET))
dataListenerJobs.add(observeBackupChanges(paykitSdkService.backupStateVersion, BackupCategory.WALLET))
Expand Down Expand Up @@ -548,6 +565,9 @@ class BackupRepo @Inject constructor(
val hardwareTagMetadata = activityRepo.getHardwareTagsAsPreActivityMetadata().getOrThrow()
val tagMetadata = (preActivityMetadata + hardwareTagMetadata)
.distinctBy { it.walletId to it.paymentId }
// Like the tags above, this envelope is the only copy of the names, so a read failure must
// propagate and fail the backup rather than upload an empty set over the stored ones.
val hwWalletNames = hwWalletStore.backupSnapshot().takeIf { it.isNotEmpty() }
val cacheData = cacheStore.data.first()
val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null)
val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null)
Expand All @@ -558,6 +578,7 @@ class BackupRepo @Inject constructor(
cache = cacheData,
pubkySession = pubkySession,
pubkyContactProfileOverrides = pubkyContactProfileOverrides,
hwWalletNames = hwWalletNames,
)

json.encodeToString(payload).toByteArray()
Expand Down Expand Up @@ -661,7 +682,13 @@ class BackupRepo @Inject constructor(
.onFailure {
Logger.warn("Failed to restore pubky contact profile overrides", it, context = TAG)
}
// App-owned, so it takes no part in the Core field migration above and never sets needsRewrite.
// Restored names wait as pending ones until each wallet is paired again. Failing to store them
// must not discard the rest of this envelope, which has already been applied by here.
runSuspendCatching { hwWalletStore.restoreNames(parsed.hwWalletNames.orEmpty()) }
.onFailure { Logger.warn("Failed to restore hardware wallet names", it, context = TAG) }
Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG)
Logger.debug("Restored ${parsed.hwWalletNames.orEmpty().size} hardware wallet names", TAG)

return RestoredCoreBackup(createdAt = parsed.createdAt, needsRewrite = migration.changed && persisted)
}
Expand Down
67 changes: 58 additions & 9 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import to.bitkit.async.appScope
import to.bitkit.data.HwWalletStore
import to.bitkit.data.PendingNameUpdate
import to.bitkit.data.SettingsStore
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
Expand Down Expand Up @@ -77,6 +78,7 @@ import kotlin.time.Duration.Companion.seconds
class HwWalletRepo @Inject constructor(
private val trezorRepo: TrezorRepo,
private val activityRepo: ActivityRepo,
private val preActivityMetadataRepo: PreActivityMetadataRepo,
private val hwWalletStore: HwWalletStore,
private val settingsStore: SettingsStore,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
Expand Down Expand Up @@ -297,9 +299,11 @@ class HwWalletRepo @Inject constructor(

Logger.warn("Rejected hardware session for '$walletId': opened wallet '$opened'", context = TAG)
// Reading the accounts of the wrong wallet already stored it; a mistyped passphrase
// must not leave a stray watch-only wallet behind.
// must not leave a stray watch-only wallet behind. Its backup data is kept: the wallet
// is a real one the user owns, and storing it has already consumed any name restored
// for it into the entry about to be forgotten.
if (opened !in watchedBefore) {
removeDevice(opened)
removeDevice(opened, keepBackupData = true)
.onFailure { Logger.warn("Failed to drop unwatched wallet '$opened'", it, context = TAG) }
}
trezorRepo.disconnectStaleSession(deviceId)
Expand Down Expand Up @@ -444,15 +448,32 @@ class HwWalletRepo @Inject constructor(
* is stored once per transport but shares an xpub-derived identity, so forgetting a single id
* would leave the tile reappearing through the other transport. Other identities on the same
* device — the standard wallet, or another passphrase wallet — are left paired.
*
* @param keepBackupData whether to carry the wallet's name and tags in the backup, so re-pairing
* the device restores them. Off by default: only a user removing a wallet is asked, and internal
* cleanup of a wallet the user never meant to watch must not leave its data behind.
*/
suspend fun removeDevice(walletId: String): Result<Unit> = withContext(ioDispatcher) {
suspend fun removeDevice(
walletId: String,
keepBackupData: Boolean = false,
): Result<Unit> = withContext(ioDispatcher) {
runSuspendCatching {
watcherMutex.withLock {
val knownDevices = hwWalletStore.loadKnownDevices()
val targets = knownDevices.filter { it.resolvedWalletId() == walletId }
// Without an entry there is nothing to forget, and the check below would pass on an
// empty set: report the failure instead of telling the user the wallet was removed.
require(targets.isNotEmpty()) { "Unknown hardware wallet '$walletId'" }
// Read before the deletion below, which takes the tags with the activities they are on.
val keptName = targets.firstNotNullOfOrNull { it.customLabel?.takeIf(String::isNotBlank) }
.takeIf { keepBackupData }
// Nothing has been deleted yet, so failing here costs nothing and keeps the choice with
// the user: retry, or remove the wallet without keeping its data.
val keptTagMetadata = when {
keepBackupData -> activityRepo.getTagMetadataForWallet(walletId)
.getOrElse { throw HwBackupDataUnreadableError(it) }
else -> emptyList()
}
activeWatchers.toList()
.filter { it.toWalletId() == walletId }
.forEach {
Expand All @@ -461,10 +482,25 @@ class HwWalletRepo @Inject constructor(
}
}
activityRepo.deleteForWallet(walletId).getOrThrow()
// Written back only now: the deletion above drops the wallet's stored tag metadata
// along with its activities. Core re-attaches these once the watcher recreates them,
// so re-pairing the device brings the tags back.
if (keptTagMetadata.isNotEmpty()) {
// Nothing to roll back to at this point, and the wallet is already half removed,
// so a failure here loses the tags rather than failing the removal.
preActivityMetadataRepo.upsertPreActivityMetadata(keptTagMetadata)
}
trackedWalletIds -= walletId
lastPersistedHwSnapshots -= walletId
val failures = targets.mapNotNull {
trezorRepo.forgetDevice(it.id, walletKey = it.walletKey).exceptionOrNull()
// The name is stored in the same write that forgets the entries carrying it, so the
// store never publishes a device list still holding this wallet. A separate write would,
// and a reconcile reading it restarts the watcher of the wallet being removed.
val failures = targets.mapNotNull { device ->
trezorRepo.forgetDevice(
device.id,
walletKey = device.walletKey,
pendingName = PendingNameUpdate(walletId, keptName),
).exceptionOrNull()
}
val remaining = hwWalletStore.loadKnownDevices()
failures.firstOrNull()?.let { throw it }
Expand Down Expand Up @@ -657,15 +693,21 @@ class HwWalletRepo @Inject constructor(
knownDevices: List<KnownDevice>,
watcherSettings: WatcherSettings,
) {
val persistedWalletIds = activityRepo.getWalletIds().getOrDefault(emptySet())
.filterNot { it == WalletScope.default }
.toSet()
watcherMutex.withLock {
// Read under the lock: a removal deletes a wallet's activities while holding it, and this
// set decides what to delete. Reading it first would let a removal complete in between and
// then be undone here, taking the tag metadata it deliberately kept with it.
val persistedWalletIds = activityRepo.getWalletIds().getOrDefault(emptySet())
.filterNot { it == WalletScope.default }
.toSet()
val specs = knownDevices.toWatcherSpecs(watcherSettings.electrumUrl)
val desiredIds = specs.map { it.watcherId }.toSet()
val knownWalletIds = knownDevices.mapNotNull { it.resolvedWalletId() }.toSet()
trackedWalletIds += persistedWalletIds
val removedWalletIds = trackedWalletIds - knownWalletIds
// Only wallets that still have activities to clear. Core drops a wallet's tag metadata
// along with its activities whether or not any matched, so cleaning up a wallet that has
// none is not a no-op: it takes the metadata a removal deliberately kept.
val removedWalletIds = (trackedWalletIds - knownWalletIds).intersect(persistedWalletIds)
trackedWalletIds += knownWalletIds

specs.forEach { spec ->
Expand Down Expand Up @@ -862,6 +904,13 @@ class HwPassphraseRequiredError : AppError("Passphrase needed to reopen this wal
/** The entered passphrase opened a different wallet than the one being signed from. */
class HwPassphraseMismatchError : AppError("Passphrase opened a different wallet")

/**
* A removal asked to keep the wallet's backup data, but its tags could not be read. Raised before
* anything is deleted, so the wallet is untouched and the removal can be retried or repeated without
* keeping the data.
*/
class HwBackupDataUnreadableError(cause: Throwable) : AppError("Could not read the backup data", cause)

private data class HwWatcherData(
val walletId: String,
val addressType: String,
Expand Down
Loading
Loading