diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt new file mode 100644 index 0000000000..4a3947526b --- /dev/null +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -0,0 +1,408 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import com.synonym.bitkitcore.serializedExtendedPubkey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import to.bitkit.data.serializers.WatchOnlyAccountDataSerializer +import to.bitkit.di.IoDispatcher +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.watchOnlyAccountDataStore: DataStore by dataStore( + fileName = "watch_only_accounts.json", + serializer = WatchOnlyAccountDataSerializer, +) + +@Singleton +class WatchOnlyAccountXpubSerializer @Inject constructor() { + fun serialize(xpub: String): ByteArray = serializedExtendedPubkey(xpub) +} + +@Singleton +class WatchOnlyAccountStore @Inject constructor( + @ApplicationContext context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val xpubSerializer: WatchOnlyAccountXpubSerializer, +) { + private val store = context.watchOnlyAccountDataStore + + val data: Flow = store.data + + suspend fun load(): List = withContext(ioDispatcher) { + store.data.first().accounts + } + + suspend fun loadReconciliationState(): WatchOnlyAccountReconciliationState = withContext(ioDispatcher) { + store.data.first().let { data -> + WatchOnlyAccountReconciliationState( + accounts = data.accounts, + accountsPendingRemoval = data.accountsPendingRemoval, + ) + } + } + + suspend fun backupSnapshot(): WatchOnlyAccountBackupSnapshot = withContext(ioDispatcher) { + store.data.first().let { data -> + WatchOnlyAccountBackupSnapshot( + accounts = data.accounts, + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = data.highestAccountIndexByWallet, + pendingAccountIndexByRequest = data.pendingAccountIndexByRequest, + ), + ) + } + } + + suspend fun save(accounts: List) = withContext(ioDispatcher) { + store.updateData { current -> + current.copy( + accounts = accounts.sortedBy(WatchOnlyAccountRecord::accountIndex), + highestAccountIndexByWallet = current.highestAccountIndexByWallet.withAccountIndexes(accounts), + ) + } + Unit + } + + suspend fun restore( + accounts: List, + allocationState: WatchOnlyAccountAllocationState? = null, + ) = withContext(ioDispatcher) { + store.updateData { current -> + current.restoreAccounts(accounts, allocationState, xpubSerializer::serialize) + } + Unit + } + + suspend fun clear() = withContext(ioDispatcher) { + store.updateData { WatchOnlyAccountData() } + Unit + } + + suspend fun reserveAccountIndex(walletIndex: Int, requestFingerprint: String): Int = withContext(ioDispatcher) { + var reservedIndex: Int? = null + store.updateData { current -> + current.reserveAccountIndex(walletIndex, requestFingerprint).also { + reservedIndex = it.accountIndex + }.data + } + checkNotNull(reservedIndex) + } + + suspend fun markActive(id: String) = withContext(ioDispatcher) { + store.updateData { current -> current.markAccountActive(id) } + Unit + } + + suspend fun completeReconciliation(walletIndex: Int) = withContext(ioDispatcher) { + store.updateData { current -> current.completeReconciliation(walletIndex) } + Unit + } + + suspend fun update(transform: (List) -> List) = + withContext(ioDispatcher) { + store.updateData { current -> + current.copy(accounts = transform(current.accounts).sortedBy(WatchOnlyAccountRecord::accountIndex)) + } + Unit + } +} + +@Serializable +data class WatchOnlyAccountData( + val accounts: List = emptyList(), + val accountsPendingRemoval: List = emptyList(), + val highestAccountIndexByWallet: Map = emptyMap(), + val pendingAccountIndexByRequest: Map = emptyMap(), +) + +data class WatchOnlyAccountReconciliationState( + val accounts: List, + val accountsPendingRemoval: List, +) + +@Serializable +data class WatchOnlyAccountAllocationState( + val highestAccountIndexByWallet: Map = emptyMap(), + val pendingAccountIndexByRequest: Map = emptyMap(), +) + +data class WatchOnlyAccountBackupSnapshot( + val accounts: List, + val allocationState: WatchOnlyAccountAllocationState, +) + +internal data class WatchOnlyAccountIndexReservation( + val data: WatchOnlyAccountData, + val accountIndex: Int, +) + +internal fun WatchOnlyAccountData.reserveAccountIndex( + walletIndex: Int, + requestFingerprint: String, +): WatchOnlyAccountIndexReservation { + val requestKey = "$walletIndex:$requestFingerprint" + pendingAccountIndexByRequest[requestKey]?.let { + return WatchOnlyAccountIndexReservation(data = this, accountIndex = it) + } + + val walletKey = walletIndex.toString() + val highestPersistedIndex = accounts + .filter { it.walletIndex == walletIndex } + .maxOfOrNull(WatchOnlyAccountRecord::accountIndex) ?: 0 + val highestAccountIndex = maxOf(highestAccountIndexByWallet[walletKey] ?: 0, highestPersistedIndex) + check(highestAccountIndex < Int.MAX_VALUE) { "Watch-only account index overflow" } + + val reservedIndex = highestAccountIndex + 1 + return WatchOnlyAccountIndexReservation( + data = copy( + highestAccountIndexByWallet = highestAccountIndexByWallet + (walletKey to reservedIndex), + pendingAccountIndexByRequest = pendingAccountIndexByRequest + (requestKey to reservedIndex), + ), + accountIndex = reservedIndex, + ) +} + +internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccountData { + val account = checkNotNull(accounts.firstOrNull { it.id == id }) { + "Watch-only account '$id' not found" + } + val requestKey = account.allocationRequestKey() + val updatedAccounts = accounts.map { + if (it.id == id) { + it.copy(isTrackingEnabled = true, setupState = WatchOnlyAccountSetupState.Active) + } else { + it + } + } + return copy( + accounts = updatedAccounts, + pendingAccountIndexByRequest = pendingAccountIndexByRequest - requestKey, + ) +} + +internal fun WatchOnlyAccountData.restoreAccounts( + accounts: List, + allocationState: WatchOnlyAccountAllocationState? = null, + serializeXpub: (String) -> ByteArray, +): WatchOnlyAccountData { + val restoredAccounts = accounts.sanitizedAccounts(serializeXpub) + val locallyManagedAccounts = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval) + val protectedLocalAccounts = locallyManagedAccounts + .filter { localAccount -> + localAccount.setupState == WatchOnlyAccountSetupState.Authorizing || + localAccount.shouldPreserveFrom(restoredAccounts) + } + .sanitizedAccounts(serializeXpub) + val mergedAccounts = (protectedLocalAccounts + restoredAccounts).sanitizedAccounts(serializeXpub) + val mergedManagementKeys = mergedAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) + val updatedAccountsPendingRemoval = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval) + .filterNot { it.managementKey() in mergedManagementKeys } + + val validLocalPendingAccountIndexes = pendingAccountIndexByRequest.validPendingAccountIndexes() + val localHighestIndexes = highestAccountIndexByWallet + .validHighestAccountIndexes() + .withAccountIndexes(locallyManagedAccounts) + .withPendingAccountIndexes(validLocalPendingAccountIndexes) + val highestIndexes = allocationState?.highestAccountIndexByWallet + .orEmpty() + .validHighestAccountIndexes() + .entries + .fold(localHighestIndexes) { indexes, (wallet, index) -> + indexes + (wallet to maxOf(indexes[wallet] ?: 0, index)) + } + .withAccountIndexes(locallyManagedAccounts + accounts + updatedAccountsPendingRemoval) + + val retainedLocalPendingAccountIndexes = if (allocationState == null) { + emptyMap() + } else { + validLocalPendingAccountIndexes + } + val mergedPendingAccountIndexes = mergedPendingAccountIndexes( + accounts = mergedAccounts, + blockedAccounts = updatedAccountsPendingRemoval, + localPendingAccountIndexes = retainedLocalPendingAccountIndexes, + restoredPendingAccountIndexes = allocationState?.pendingAccountIndexByRequest.orEmpty(), + localHighestAccountIndexByWallet = localHighestIndexes, + ) + + return copy( + accounts = mergedAccounts, + accountsPendingRemoval = updatedAccountsPendingRemoval, + highestAccountIndexByWallet = highestIndexes.withPendingAccountIndexes(mergedPendingAccountIndexes), + pendingAccountIndexByRequest = mergedPendingAccountIndexes, + ) +} + +private fun WatchOnlyAccountRecord.managementKey(): String = "$walletIndex:$addressType:$accountIndex" + +private fun WatchOnlyAccountRecord.allocationRequestKey(): String = "$walletIndex:$requestFingerprint" + +private fun WatchOnlyAccountRecord.normalizedTrackingState(): WatchOnlyAccountRecord = when (setupState) { + WatchOnlyAccountSetupState.PendingDelivery -> copy(isTrackingEnabled = false) + WatchOnlyAccountSetupState.Authorizing -> copy(isTrackingEnabled = true) + WatchOnlyAccountSetupState.Active -> this +} + +private fun WatchOnlyAccountRecord.isUsableAccount( + serializeXpub: (String) -> ByteArray, +): Boolean = walletIndex >= 0 && + accountIndex > 0 && + addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE && + runCatching { serializeXpub(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH } + .getOrDefault(false) + +private fun List.sanitizedAccounts( + serializeXpub: (String) -> ByteArray, +): List { + val ids = mutableSetOf() + val managementKeys = mutableSetOf() + val incompleteRequestKeys = mutableSetOf() + + return mapNotNull { input -> + if (!input.isUsableAccount(serializeXpub)) return@mapNotNull null + val account = input.normalizedTrackingState() + val incompleteRequestKey = account + .takeIf { it.setupState != WatchOnlyAccountSetupState.Active } + ?.allocationRequestKey() + if (account.id in ids || account.managementKey() in managementKeys) return@mapNotNull null + if (incompleteRequestKey != null && incompleteRequestKey in incompleteRequestKeys) return@mapNotNull null + ids += account.id + managementKeys += account.managementKey() + incompleteRequestKey?.let(incompleteRequestKeys::add) + account + }.sortedWith( + compareBy( + WatchOnlyAccountRecord::walletIndex, + WatchOnlyAccountRecord::accountIndex, + WatchOnlyAccountRecord::createdAt, + ), + ) +} + +private fun uniqueAccountsByManagementKey(accounts: List): List = + accounts.distinctBy(WatchOnlyAccountRecord::managementKey) + .sortedWith(compareBy(WatchOnlyAccountRecord::walletIndex, WatchOnlyAccountRecord::accountIndex)) + +private fun WatchOnlyAccountRecord.shouldPreserveFrom( + restoredAccounts: List, +): Boolean { + val conflicts = restoredAccounts.filter { + it.id == id || it.managementKey() == managementKey() + } + if (conflicts.isEmpty()) return false + if (conflicts.any { !hasSameOwner(it) }) return true + return setupState == WatchOnlyAccountSetupState.Active && + conflicts.all { it.setupState != WatchOnlyAccountSetupState.Active } +} + +private fun WatchOnlyAccountRecord.hasSameOwner(other: WatchOnlyAccountRecord): Boolean = + managementKey() == other.managementKey() && + requestFingerprint == other.requestFingerprint && + xpub == other.xpub + +private data class AccountIndexKey( + val walletIndex: Int, + val accountIndex: Int, +) + +private fun mergedPendingAccountIndexes( + accounts: List, + blockedAccounts: List, + localPendingAccountIndexes: Map, + restoredPendingAccountIndexes: Map, + localHighestAccountIndexByWallet: Map, +): Map { + val activeSlots = accounts + .filter { it.setupState == WatchOnlyAccountSetupState.Active } + .mapTo(mutableSetOf()) { AccountIndexKey(it.walletIndex, it.accountIndex) } + val blockedSlots = blockedAccounts + .mapTo(mutableSetOf()) { AccountIndexKey(it.walletIndex, it.accountIndex) } + val pendingAccountIndexes = linkedMapOf() + val reservedSlots = mutableSetOf() + + fun reserve(requestKey: String, accountIndex: Int, allowsHistoricalIndex: Boolean) { + val walletIndex = requestKey.allocationWalletIndex() ?: return + val slot = AccountIndexKey(walletIndex, accountIndex) + if (requestKey in pendingAccountIndexes || accountIndex <= 0) return + if (slot in reservedSlots || slot in activeSlots || slot in blockedSlots) return + if ( + !allowsHistoricalIndex && + accountIndex <= (localHighestAccountIndexByWallet[walletIndex.toString()] ?: 0) + ) { + return + } + pendingAccountIndexes[requestKey] = accountIndex + reservedSlots += slot + } + + accounts.filter { it.setupState != WatchOnlyAccountSetupState.Active }.forEach { + reserve(it.allocationRequestKey(), it.accountIndex, allowsHistoricalIndex = true) + } + localPendingAccountIndexes.toSortedMap().forEach { (requestKey, accountIndex) -> + reserve(requestKey, accountIndex, allowsHistoricalIndex = true) + } + restoredPendingAccountIndexes.toSortedMap().forEach { (requestKey, accountIndex) -> + reserve(requestKey, accountIndex, allowsHistoricalIndex = false) + } + + return pendingAccountIndexes +} + +private fun Map.validHighestAccountIndexes(): Map = + entries.fold(emptyMap()) { indexes, (wallet, index) -> + val walletIndex = wallet.toIntOrNull()?.takeIf { it >= 0 } + ?: return@fold indexes + if (index <= 0) return@fold indexes + val walletKey = walletIndex.toString() + indexes + (walletKey to maxOf(indexes[walletKey] ?: 0, index)) + } + +private fun Map.validPendingAccountIndexes(): Map = + filter { (requestKey, accountIndex) -> + requestKey.allocationWalletIndex() != null && accountIndex > 0 + } + +private fun String.allocationWalletIndex(): Int? { + val separatorIndex = indexOf(':') + if (separatorIndex <= 0 || separatorIndex == lastIndex) return null + return substring(0, separatorIndex).toIntOrNull()?.takeIf { it >= 0 } +} + +private fun Map.withPendingAccountIndexes( + pendingAccountIndexes: Map, +): Map { + val updated = toMutableMap() + pendingAccountIndexes.forEach { (requestKey, accountIndex) -> + val walletIndex = requestKey.allocationWalletIndex() ?: return@forEach + if (accountIndex <= 0) return@forEach + val walletKey = walletIndex.toString() + updated[walletKey] = maxOf(updated[walletKey] ?: 0, accountIndex) + } + return updated +} + +internal fun WatchOnlyAccountData.completeReconciliation(walletIndex: Int): WatchOnlyAccountData = copy( + accountsPendingRemoval = accountsPendingRemoval.filterNot { it.walletIndex == walletIndex }, +) + +private fun Map.withAccountIndexes(accounts: List): Map { + val updated = toMutableMap() + accounts.filter { it.walletIndex >= 0 && it.accountIndex > 0 } + .groupBy(WatchOnlyAccountRecord::walletIndex).forEach { (walletIndex, walletAccounts) -> + val highestAccountIndex = walletAccounts.maxOf(WatchOnlyAccountRecord::accountIndex) + val walletKey = walletIndex.toString() + updated[walletKey] = maxOf(updated[walletKey] ?: 0, highestAccountIndex) + } + return updated +} diff --git a/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt new file mode 100644 index 0000000000..997f90828d --- /dev/null +++ b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt @@ -0,0 +1,23 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import kotlinx.serialization.SerializationException +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.di.json +import java.io.InputStream +import java.io.OutputStream + +object WatchOnlyAccountDataSerializer : Serializer { + override val defaultValue = WatchOnlyAccountData() + + override suspend fun readFrom(input: InputStream): WatchOnlyAccountData = try { + json.decodeFromString(input.readBytes().decodeToString()) + } catch (error: SerializationException) { + throw CorruptionException("Failed to deserialize watch-only account data", error) + } + + override suspend fun writeTo(t: WatchOnlyAccountData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index f08ae0394f..04dfeb68bb 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData import to.bitkit.data.SettingsData +import to.bitkit.data.WatchOnlyAccountAllocationState import to.bitkit.data.WidgetsData import to.bitkit.data.entities.TransferEntity @@ -21,6 +22,8 @@ data class WalletBackupV1( val transfers: List, val privatePaykitHighestReservedReceiveIndexByAddressType: Map? = null, val paykitSdkBackupState: String? = null, + val watchOnlyAccounts: List? = null, + val watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null, ) @Serializable diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 048f00ea98..a212b0b259 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -1,12 +1,42 @@ package to.bitkit.models import androidx.compose.runtime.Immutable +import to.bitkit.utils.AppError +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +enum class PubkyAuthClaim(val wireValue: String) { + WATCH_ONLY_ACCOUNT_V1("watch-only-account-v1"), + ; + + companion object { + /** Query parameter used for Bitkit-specific Pubky auth claims. */ + const val QUERY_PARAMETER = "x-bitkit-claim" + + /** Capabilities required by the watch-only Paykit Server setup flow. */ + const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw" + + fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } + } +} + +sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) { + class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause) + data object MissingBitkitClaim : PubkyAuthRequestError() + data object DuplicateBitkitClaim : PubkyAuthRequestError() + data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError() + data object InvalidBitkitClaimCapabilities : PubkyAuthRequestError() +} @Immutable data class PubkyAuthPermission( val path: String, val accessLevel: String, ) { + val displayPath: String + get() = if (path.length > 1) path.removeSuffix("/") else path + val displayAccess: String get() = accessLevel.map { char -> when (char) { @@ -20,10 +50,71 @@ data class PubkyAuthPermission( data class PubkyAuthRequest( val rawUrl: String, val relay: String, + val capabilities: String, val permissions: List, val serviceNames: List, + val bitkitClaim: PubkyAuthClaim?, ) { companion object { + fun parse( + rawUrl: String, + relay: String, + capabilities: String, + ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> + val permissions = parseCapabilities(capabilities) + PubkyAuthRequest( + rawUrl = rawUrl, + relay = relay, + capabilities = capabilities, + permissions = permissions, + serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(), + bitkitClaim = bitkitClaim, + ) + } + + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = + parseBitkitClaimValues(rawUrl).fold( + onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, + onFailure = { Result.failure(it) }, + ) + + private fun parseBitkitClaimValues(rawUrl: String): Result> = runCatching { + URI(rawUrl).rawQuery.orEmpty() + .split("&") + .filter { it.isNotEmpty() } + .map { it.split("=", limit = 2) } + .filter { decodeQueryComponent(it.first()) == PubkyAuthClaim.QUERY_PARAMETER } + .map { decodeQueryComponent(it.getOrElse(1) { "" }) } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) }, + ) + + private fun validateBitkitClaim( + claimValues: List, + capabilities: String, + ): Result = when { + claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) + claimValues.isEmpty() && capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES -> + Result.failure(PubkyAuthRequestError.MissingBitkitClaim) + claimValues.isEmpty() -> Result.success(null) + else -> validateBitkitClaimValue(claimValues.first(), capabilities) + } + + private fun validateBitkitClaimValue( + claimValue: String, + capabilities: String, + ): Result { + val claim = PubkyAuthClaim.fromWireValue(claimValue) + ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) + + return if (capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES) { + Result.success(claim) + } else { + Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) + } + } + fun parseCapabilities(caps: String): List = caps.split(",") .filter { it.isNotBlank() } @@ -40,5 +131,7 @@ data class PubkyAuthRequest( val pubIndex = parts.indexOf("pub") return if (pubIndex >= 0 && pubIndex + 1 < parts.size) parts[pubIndex + 1] else null } + + private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name()) } } diff --git a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt new file mode 100644 index 0000000000..f632bfc26c --- /dev/null +++ b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt @@ -0,0 +1,49 @@ +package to.bitkit.models + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.lightningdevkit.ldknode.Network +import to.bitkit.env.Env + +const val WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX = 999 +const val WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE = "nativeSegwit" +const val WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH = 78 + +@Serializable +enum class WatchOnlyAccountSetupState { + @SerialName("pendingDelivery") + PendingDelivery, + + @SerialName("authorizing") + Authorizing, + + @SerialName("active") + Active, +} + +@Serializable +@Immutable +data class WatchOnlyAccountRecord( + val id: String, + val walletIndex: Int, + val accountIndex: Int, + val addressType: String, + val xpub: String, + val requestFingerprint: String, + val createdAt: Long, + val name: String, + val isTrackingEnabled: Boolean, + val setupState: WatchOnlyAccountSetupState, +) { + val derivationPath: String + get() { + val coinType = if (Env.network == Network.BITCOIN) 0 else 1 + return "m/84'/$coinType'/$accountIndex'" + } +} + +data class PreparedWatchOnlyAccountClaim( + val account: WatchOnlyAccountRecord, + val payload: ByteArray, +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0d681c7487..956e6ebfcc 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore 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 @@ -91,6 +92,8 @@ class BackupRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val watchOnlyAccountStore: WatchOnlyAccountStore, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val pubkyRepo: PubkyRepo, @@ -263,6 +266,17 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(transfersJob) + val watchOnlyAccountsJob = scope.launch { + watchOnlyAccountStore.data + .distinctUntilChanged() + .drop(1) + .collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(BackupCategory.WALLET) + } + } + dataListenerJobs.add(watchOnlyAccountsJob) + // METADATA - Observe entire CacheStore excluding backup statuses val cacheMetadataJob = scope.launch { cacheStore.data @@ -545,11 +559,14 @@ class BackupRepo @Inject constructor( } .getOrThrow() + val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val payload = WalletBackupV1( createdAt = currentTimeMillis(), transfers = transfers, privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, paykitSdkBackupState = paykitSdkBackupState, + watchOnlyAccounts = watchOnlyAccountSnapshot.accounts, + watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, ) return json.encodeToString(payload).toByteArray() @@ -631,6 +648,11 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) + watchOnlyAccountRepo.restore( + parsed.watchOnlyAccounts.orEmpty(), + parsed.watchOnlyAccountAllocationState, + ) + lightningService.reconcileWatchOnlyAccounts() if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) { cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..982e7e5375 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -65,6 +65,7 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS @@ -73,6 +74,7 @@ import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.models.satsToMsat import to.bitkit.models.toAddressType @@ -88,7 +90,6 @@ 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 @@ -340,8 +341,12 @@ class LightningRepo @Inject constructor( } } - if (getStatus()?.isRunning == true) { + if (lightningService.status?.isRunning == true) { Logger.info("LDK node already running", context = TAG) + runSuspendCatching { lightningService.reconcileWatchOnlyAccounts() } + .onFailure { + Logger.warn("Failed to reconcile Paykit Server accounts during startup", it, context = TAG) + } _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } lightningService.startEventListener(::onEvent).onFailure { Logger.warn("Failed to start event listener", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index cd416318a8..4df0de0532 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -5,7 +5,7 @@ import android.graphics.BitmapFactory import coil3.ImageLoader import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile -import com.synonym.paykit.PubkyAuthDetails +import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -41,6 +41,8 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink @@ -898,9 +900,14 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(publicKey) != null }.getOrDefault(false) - suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { - pubkyService.parseAuthUrl(authUrl) + val details = pubkyService.parseAuthUrl(authUrl) + PubkyAuthRequest.parse( + rawUrl = authUrl, + relay = details.relayUrl.orEmpty(), + capabilities = details.capabilities.orEmpty(), + ).getOrThrow() } } @@ -913,6 +920,27 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveAuthWithCompanionClaim( + authUrl: String, + unsignedPayload: ByteArray, + ): Result = runSuspendCatching { + withContext(ioDispatcher) { + val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { + "No secret key available — use Ring to manage authorizations" + } + pubkyService.approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + secretKeyHex = secretKeyHex, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + unsignedPayload = unsignedPayload, + ), + ) + } + } + // endregion // region Backup state diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt new file mode 100644 index 0000000000..1df5fe6017 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -0,0 +1,357 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.AddressType +import to.bitkit.async.ServiceQueue +import to.bitkit.data.WatchOnlyAccountAllocationState +import to.bitkit.data.WatchOnlyAccountStore +import to.bitkit.data.WatchOnlyAccountXpubSerializer +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PreparedWatchOnlyAccountClaim +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.services.LightningService +import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator +import to.bitkit.utils.AppError +import java.net.URI +import java.net.URLDecoder +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.ExperimentalTime + +sealed class WatchOnlyAccountError : AppError() { + data object AuthorizationAccountMissing : WatchOnlyAccountError() + data object InvalidAccountName : WatchOnlyAccountError() + data object InvalidExtendedPublicKey : WatchOnlyAccountError() + data object NodeUnavailable : WatchOnlyAccountError() +} + +class WatchOnlyAccountAuthorizationStartError( + val preserveAuthorizingState: Boolean, + cause: Throwable, +) : AppError(cause.message, cause.cause ?: cause) + +@Singleton +@OptIn(ExperimentalTime::class) +class WatchOnlyAccountRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val store: WatchOnlyAccountStore, + private val lightningService: LightningService, + private val lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, + private val xpubSerializer: WatchOnlyAccountXpubSerializer, +) { + val accounts: Flow> = store.data.map { it.accounts } + val currentWalletAccounts: Flow> = accounts.map { accounts -> + accounts.filter { it.walletIndex == lightningService.currentWalletIndex } + } + val currentWalletAccountCount: Flow = currentWalletAccounts.map { it.size } + + suspend fun prepareUnsignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim = + withContext(bgDispatcher) { + lifecycleCoordinator.withLock { + val normalizedName = normalizeName(name) + val walletIndex = lightningService.currentWalletIndex + val fingerprint = requestFingerprint(authUrl) + val current = store.load() + current.firstOrNull { + it.walletIndex == walletIndex && + it.requestFingerprint == fingerprint && + it.setupState != WatchOnlyAccountSetupState.Active + }?.let { existing -> + val refreshed = existing.copy(name = normalizedName) + if (refreshed != existing) { + store.save(current.map { if (it.id == existing.id) refreshed else it }) + } + return@withLock PreparedWatchOnlyAccountClaim( + account = refreshed, + payload = WatchOnlyAccountClaimCodec.encode(refreshed, xpubSerializer::serialize), + ) + } + + val accountIndex = store.reserveAccountIndex(walletIndex, fingerprint) + val xpub = exportAccountXpub(accountIndex) + val account = WatchOnlyAccountRecord( + id = UUID.randomUUID().toString(), + walletIndex = walletIndex, + accountIndex = accountIndex, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = xpub, + requestFingerprint = fingerprint, + createdAt = nowMillis(), + name = normalizedName, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + store.save(current + account) + PreparedWatchOnlyAccountClaim( + account = account, + payload = WatchOnlyAccountClaimCodec.encode(account, xpubSerializer::serialize), + ) + } + } + + suspend fun markActive(id: String) = withContext(NonCancellable) { + lifecycleCoordinator.withLock { + if (store.load().none { it.id == id }) { + throw WatchOnlyAccountError.AuthorizationAccountMissing + } + store.markActive(id) + } + } + + suspend fun beginAuthorization(id: String) = withContext(NonCancellable) { + lifecycleCoordinator.withLock { + val account = store.load().firstOrNull { + it.id == id && it.setupState != WatchOnlyAccountSetupState.Active + } ?: throw WatchOnlyAccountError.AuthorizationAccountMissing + val preserveAuthorizingState = account.setupState == WatchOnlyAccountSetupState.Authorizing + runSuspendCatching { + setAccountTracking(account, enabled = true) + val updateResult = runSuspendCatching { + store.update { accounts -> + accounts.map { + if (it.id == id) { + it.copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + } else { + it + } + } + } + } + updateResult.onFailure { + runSuspendCatching { setAccountTracking(account, enabled = account.isTrackingEnabled) } + } + updateResult.getOrThrow() + }.getOrElse { + throw WatchOnlyAccountAuthorizationStartError(preserveAuthorizingState, it) + } + preserveAuthorizingState + } + } + + suspend fun cancelAuthorization( + id: String, + preserveAuthorizingState: Boolean = false, + ) = withContext(NonCancellable) { + lifecycleCoordinator.withLock { + val current = store.load() + val account = current.firstOrNull { + it.id == id && it.setupState != WatchOnlyAccountSetupState.Active + } ?: return@withLock + val shouldPreserveAuthorizingState = preserveAuthorizingState && + account.setupState == WatchOnlyAccountSetupState.Authorizing + setAccountTracking(account, enabled = shouldPreserveAuthorizingState) + val saveResult = runSuspendCatching { + store.save( + current.map { + if (it.id == id) { + it.copy( + isTrackingEnabled = shouldPreserveAuthorizingState, + setupState = if (shouldPreserveAuthorizingState) { + WatchOnlyAccountSetupState.Authorizing + } else { + WatchOnlyAccountSetupState.PendingDelivery + }, + ) + } else { + it + } + }, + ) + } + saveResult.onFailure { + runSuspendCatching { + setAccountTracking(account, enabled = account.isTrackingEnabled) + } + } + saveResult.getOrThrow() + } + } + + suspend fun rename(id: String, name: String) { + val normalizedName = normalizeName(name) + updateAccount(id) { it.copy(name = normalizedName) } + } + + suspend fun setTrackingEnabled(id: String, enabled: Boolean) = withContext(NonCancellable) { + lifecycleCoordinator.withLock { + val current = store.load() + val account = current.firstOrNull { it.id == id } ?: return@withLock + if (account.setupState != WatchOnlyAccountSetupState.Active) return@withLock + if (account.isTrackingEnabled == enabled) return@withLock + + setAccountTracking(account, enabled) + val saveResult = runSuspendCatching { + store.save(current.map { if (it.id == id) it.copy(isTrackingEnabled = enabled) else it }) + } + saveResult.onFailure { + runSuspendCatching { setAccountTracking(account, enabled = !enabled) } + } + saveResult.getOrThrow() + } + } + + suspend fun restore( + accounts: List?, + allocationState: WatchOnlyAccountAllocationState? = null, + ) { + lifecycleCoordinator.withLock { + store.restore(accounts.orEmpty(), allocationState) + } + } + + suspend fun clear() { + lifecycleCoordinator.withLock { + store.clear() + } + } + + private suspend fun exportAccountXpub(accountIndex: Int): String = ServiceQueue.LDK.background { + val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable + node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, accountIndex.toUInt()) + } + + private suspend fun setAccountTracking( + account: WatchOnlyAccountRecord, + enabled: Boolean, + ) = ServiceQueue.LDK.background { + val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable + val addressType = when (account.addressType) { + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> AddressType.NATIVE_SEGWIT + else -> throw WatchOnlyAccountError.InvalidExtendedPublicKey + } + val accountIndex = account.accountIndex.toUInt() + val isTracked = node.listOnchainWalletAccounts().any { + it.addressType == addressType && it.accountIndex == accountIndex + } + + when { + enabled -> { + val wasAdded = !isTracked + if (wasAdded) { + node.addOnchainWalletAccount(addressType, accountIndex, account.xpub) + } + val trackingResult = runSuspendCatching { + node.onchainPayment().revealReceiveAddressesToAccount( + addressType, + accountIndex, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + if (wasAdded) { + node.syncWallets() + } + } + trackingResult.onFailure { + if (wasAdded) { + runSuspendCatching { + node.removeOnchainWalletAccount(addressType, accountIndex) + } + } + } + trackingResult.getOrThrow() + } + !enabled && isTracked -> node.removeOnchainWalletAccount(addressType, accountIndex) + } + } + + private suspend fun updateAccount(id: String, transform: (WatchOnlyAccountRecord) -> WatchOnlyAccountRecord) { + lifecycleCoordinator.withLock { + store.update { accounts -> accounts.map { if (it.id == id) transform(it) else it } } + } + } + + private fun normalizeName(name: String): String { + val normalized = name.trim() + if (normalized.isEmpty() || normalized.length > MAX_NAME_LENGTH) { + throw WatchOnlyAccountError.InvalidAccountName + } + return normalized + } + + private fun requestFingerprint(authUrl: String): String { + val fingerprintSource = runCatching { + val uri = URI(authUrl) + val queryValues = uri.rawQuery.orEmpty() + .split("&") + .filter(String::isNotEmpty) + .map { it.split("=", limit = 2) } + .groupBy( + keySelector = { decodeQueryComponent(it.first()) }, + valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) }, + ) + val relay = queryValues.singleValue("relay") + val secret = queryValues.singleValue("secret") + val capabilities = queryValues.singleValue("caps") + val claim = queryValues.singleValue(PubkyAuthClaim.QUERY_PARAMETER) + listOf( + requireNotNull(uri.scheme).lowercase(), + requireNotNull(uri.host).lowercase(), + uri.path.orEmpty(), + relay, + secret, + capabilities, + claim, + ).joinToString("\u0000") + }.getOrDefault(authUrl) + return sha256(fingerprintSource.encodeToByteArray()).toBase64() + } + + companion object { + private const val MAX_NAME_LENGTH = 64 + } +} + +private fun Map>.singleValue(name: String): String { + val values = getValue(name) + return values.single().also { require(it.isNotEmpty()) } +} + +private fun decodeQueryComponent(value: String): String = + URLDecoder.decode(value, StandardCharsets.UTF_8) + +object WatchOnlyAccountClaimCodec { + const val VERSION: Byte = 1 + const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 + const val SERIALIZED_XPUB_LENGTH = WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH + const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + + fun encode( + account: WatchOnlyAccountRecord, + serializeXpub: (String) -> ByteArray, + ): ByteArray { + val rawXpub = if (account.addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE) { + runCatching { serializeXpub(account.xpub) }.getOrNull() + } else { + null + } + if (rawXpub?.size != SERIALIZED_XPUB_LENGTH) throw WatchOnlyAccountError.InvalidExtendedPublicKey + + return ByteBuffer.allocate(PAYLOAD_LENGTH) + .put(VERSION) + .putInt(account.accountIndex) + .put(NATIVE_SEGWIT_ADDRESS_TYPE) + .put(rawXpub) + .array() + } +} + +private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value) +private fun ByteArray.toBase64(): String = Base64.getEncoder().encodeToString(this) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index c58b1151cf..dc46c1df95 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -34,6 +34,7 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails @@ -45,14 +46,20 @@ import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue import to.bitkit.data.SettingsStore +import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.uByteList import to.bitkit.ext.uri import to.bitkit.models.OpenChannelResult +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.utils.AppError @@ -72,19 +79,46 @@ import org.lightningdevkit.ldknode.AddressType as LdkAddressType typealias NodeEventHandler = suspend (Event) -> Unit +internal fun enabledOnchainWalletAccountConfigs( + records: List, + walletIndex: Int, +): List = records + .filter { it.walletIndex == walletIndex } + .filter { + it.setupState == WatchOnlyAccountSetupState.Active || + it.setupState == WatchOnlyAccountSetupState.Authorizing + } + .filter { it.isTrackingEnabled } + .map { record -> + val addressType = when (record.addressType) { + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> LdkAddressType.NATIVE_SEGWIT + else -> throw IllegalArgumentException("Unsupported watch-only account address type") + } + OnchainWalletAccountConfig( + addressType = addressType, + accountIndex = record.accountIndex.toUInt(), + xpub = record.xpub, + ) + } + +private fun accountKey(addressType: LdkAddressType, accountIndex: UInt): String = + "${addressType.name}:$accountIndex" + data class AddressDerivationInfo( val address: String, val index: Int, ) -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @Singleton class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val vssStoreIdProvider: VssStoreIdProvider, private val settingsStore: SettingsStore, + private val watchOnlyAccountStore: WatchOnlyAccountStore, private val loggerLdk: LoggerLdk, + private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, ) : BaseCoroutineScope(bgDispatcher) { companion object { @@ -114,6 +148,10 @@ class LightningService @Inject constructor( @Volatile var node: Node? = null + @Volatile + var currentWalletIndex: Int = 0 + private set + private val _syncStatusChanged = MutableSharedFlow(extraBufferCapacity = 1) val syncStatusChanged: SharedFlow = _syncStatusChanged.asSharedFlow() @@ -131,18 +169,20 @@ class LightningService @Inject constructor( Logger.debug("Building node…", context = TAG) val config = config(walletIndex, trustedPeers) - node = build( + val builtNode = build( walletIndex, customServerUrl, customRgsServerUrl, config, channelMigration, ) + currentWalletIndex = walletIndex + node = builtNode Logger.info("LDK node setup", context = TAG) } - private fun config( + private suspend fun config( walletIndex: Int, trustedPeers: List?, ): Config { @@ -164,6 +204,7 @@ class LightningService @Inject constructor( ), probingLiquidityLimitMultiplier = 1uL, includeUntrustedPendingInSpendable = true, + onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex), ) } @@ -275,6 +316,8 @@ class LightningService @Inject constructor( feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, ), connectionTimeoutSecs = Env.walletSyncTimeoutSecs, + additionalWalletFullScanBatchSize = 100u, + additionalWalletFullScanStopGap = 1000u, ), ) } @@ -292,6 +335,8 @@ class LightningService @Inject constructor( } } + reconcileWatchOnlyAccountsBestEffort() + // start event listener after node started onEvent?.let { eventHandler -> shouldListenForEvents = true @@ -314,6 +359,66 @@ class LightningService @Inject constructor( Logger.info("Node started", context = TAG) } + private suspend fun reconcileWatchOnlyAccountsBestEffort() { + runSuspendCatching { + reconcileWatchOnlyAccounts() + }.onFailure { error -> + Logger.error("Failed to reconcile Paykit Server accounts during startup", error, context = TAG) + } + } + + suspend fun reconcileWatchOnlyAccounts(syncAfterReconcile: Boolean = true) { + watchOnlyAccountLifecycleCoordinator.withLock { + val node = node ?: return@withLock + val reconciliationState = watchOnlyAccountStore.loadReconciliationState() + val walletRecords = reconciliationState.accounts.filter { it.walletIndex == currentWalletIndex } + val accountsPendingRemoval = reconciliationState.accountsPendingRemoval.filter { + it.walletIndex == currentWalletIndex + } + val desiredConfigs = enabledOnchainWalletAccountConfigs(walletRecords, currentWalletIndex) + + ServiceQueue.LDK.background { + val trackedAccounts = node.listOnchainWalletAccounts() + val managedKeys = (walletRecords + accountsPendingRemoval).mapNotNull { record -> + when (record.addressType) { + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> + accountKey(LdkAddressType.NATIVE_SEGWIT, record.accountIndex.toUInt()) + else -> null + } + }.toSet() + val desiredKeys = desiredConfigs.map { accountKey(it.addressType, it.accountIndex) }.toSet() + + trackedAccounts.forEach { trackedAccount -> + val key = accountKey(trackedAccount.addressType, trackedAccount.accountIndex) + if (key in managedKeys && key !in desiredKeys) { + node.removeOnchainWalletAccount(trackedAccount.addressType, trackedAccount.accountIndex) + } + } + + desiredConfigs.forEach { config -> + val isTracked = trackedAccounts.any { + it.addressType == config.addressType && it.accountIndex == config.accountIndex + } + if (!isTracked) { + node.addOnchainWalletAccount(config.addressType, config.accountIndex, config.xpub) + } + node.onchainPayment().revealReceiveAddressesToAccount( + config.addressType, + config.accountIndex, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + } + + if (syncAfterReconcile && desiredConfigs.isNotEmpty() && node.status().isRunning) { + node.syncWallets() + } + } + if (accountsPendingRemoval.isNotEmpty()) { + watchOnlyAccountStore.completeReconciliation(currentWalletIndex) + } + } + } + suspend fun stop() { shouldListenForEvents = false listenerJob?.cancelAndJoin() @@ -416,6 +521,8 @@ class LightningService @Inject constructor( suspend fun sync() { val node = this.node ?: throw ServiceError.NodeNotSetup() + reconcileWatchOnlyAccounts(syncAfterReconcile = false) + Logger.verbose("Syncing LDK…", context = TAG) ServiceQueue.LDK.background { node.syncWallets() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 8c9146151a..54b6700ba8 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -26,6 +26,7 @@ import com.synonym.paykit.PaymentPayload import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile @@ -270,6 +271,21 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun approveAuthWithCompanionClaim( + authUrl: String, + expectedCapabilities: String, + secretKeyHex: String, + claim: PubkyAuthCompanionClaim, + ) { + isSetup.await() + PubkySessionBootstrap().approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = expectedCapabilities, + localSecretKey = localSecretKey(secretKeyHex), + claim = claim, + ) + } + suspend fun fetchFile(uri: String): ByteArray { isSetup.await() return operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 73b215ff36..cc6cd7360b 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -3,6 +3,7 @@ package to.bitkit.services import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PubkyAuthCompanionClaim import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError @@ -117,6 +118,20 @@ class PubkyService @Inject constructor( paykitSdkService.approveAuth(authUrl, expectedCapabilities, secretKeyHex) } + suspend fun approveAuthWithCompanionClaim( + authUrl: String, + expectedCapabilities: String, + secretKeyHex: String, + claim: PubkyAuthCompanionClaim, + ) = ServiceQueue.CORE.background { + paykitSdkService.approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = expectedCapabilities, + secretKeyHex = secretKeyHex, + claim = claim, + ) + } + // endregion // region File operations diff --git a/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt b/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt new file mode 100644 index 0000000000..4b81e21453 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt @@ -0,0 +1,13 @@ +package to.bitkit.services + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WatchOnlyAccountLifecycleCoordinator @Inject constructor() { + private val mutex = Mutex() + + suspend fun withLock(block: suspend () -> T): T = mutex.withLock { block() } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b41ddca8e3..7b49ae17c6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -160,6 +160,7 @@ import to.bitkit.ui.settings.advanced.AddressViewerScreen import to.bitkit.ui.settings.advanced.CoinSelectPreferenceScreen import to.bitkit.ui.settings.advanced.ElectrumConfigScreen import to.bitkit.ui.settings.advanced.RgsServerScreen +import to.bitkit.ui.settings.advanced.WatchOnlyAccountsScreen import to.bitkit.ui.settings.appStatus.AppStatusScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsIntroScreen import to.bitkit.ui.settings.backgroundPayments.BackgroundPaymentsSettings @@ -1476,6 +1477,9 @@ private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostCon composableWithDefaultTransitions { AddressViewerScreen(navController) } + composableWithDefaultTransitions { + WatchOnlyAccountsScreen(navController) + } composableWithDefaultTransitions { NodeInfoScreen(navController) } @@ -1931,6 +1935,9 @@ sealed interface Routes { @Serializable data object AddressViewer : Routes + @Serializable + data object WatchOnlyAccounts : Routes + @Serializable data object CustomFeeSettings : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index dbfcbe5403..e8a089ecf1 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -84,11 +84,13 @@ fun Headline( text: AnnotatedString, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, + textAlign: TextAlign = TextAlign.Start, ) { Text( text = text.toUpperCase(), style = AppTextStyles.Headline.merge( color = color, + textAlign = textAlign, ), modifier = modifier ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index a08f71404f..97ec5eae3f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -22,26 +23,34 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyProfile import to.bitkit.ui.appViewModel import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.CenteredProfileHeader +import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.Headline import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyImage import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.Text13Up @@ -53,6 +62,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.ui.utils.withAccent import to.bitkit.ui.utils.withAccentBoldBright @Composable @@ -62,6 +72,35 @@ fun PubkyAuthApprovalSheet( onDismiss: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(authUrl) { viewModel.load(authUrl) } + + Box { + Content( + uiState = uiState, + isCurrentRequest = uiState.authUrl == authUrl, + onAuthorize = { + if (uiState.authUrl == authUrl) viewModel.requestAuthorize(authUrl) + }, + onApproveWatchOnly = { + if (uiState.authUrl == authUrl) viewModel.approveWatchOnlyConsent(authUrl) + }, + onBackToWatchOnly = { + if (uiState.authUrl == authUrl) viewModel.returnToWatchOnlyConsent(authUrl) + }, + onCancel = { viewModel.dismiss() }, + onDismiss = { viewModel.dismiss() }, + ) + + PubkyAuthorizationLocalAuth(viewModel = viewModel, onDismiss = onDismiss) + } +} + +@Composable +private fun PubkyAuthorizationLocalAuth( + viewModel: PubkyAuthApprovalViewModel, + onDismiss: () -> Unit, +) { var showBiometrics by remember { mutableStateOf(false) } var showAuthCheck by remember { mutableStateOf(false) } var pendingAuthUrl by remember { mutableStateOf(null) } @@ -72,8 +111,6 @@ fun PubkyAuthApprovalSheet( val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() val isBiometrySupported = rememberBiometricAuthSupported() - LaunchedEffect(authUrl) { viewModel.load(authUrl) } - LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { @@ -107,43 +144,36 @@ fun PubkyAuthApprovalSheet( } } - Box { - Content( - uiState = uiState, - onAuthorize = { viewModel.requestAuthorize(authUrl) }, - onCancel = { viewModel.dismiss() }, - onDismiss = { viewModel.dismiss() }, + if (showAuthCheck) { + AuthCheckView( + appViewModel = app, + settingsViewModel = settings, + onSuccess = { + showAuthCheck = false + pendingAuthUrl?.let { viewModel.confirmAuthorize(it) } + pendingAuthUrl = null + }, + onBack = { + showAuthCheck = false + pendingAuthUrl?.let(viewModel::cancelLocalAuth) + pendingAuthUrl = null + }, ) + } - if (showAuthCheck) { - AuthCheckView( - appViewModel = app, - settingsViewModel = settings, - onSuccess = { - showAuthCheck = false - pendingAuthUrl?.let { viewModel.confirmAuthorize(it) } - pendingAuthUrl = null - }, - onBack = { - showAuthCheck = false - pendingAuthUrl = null - }, - ) - } - - if (showBiometrics) { - BiometricsView( - onSuccess = { - showBiometrics = false - pendingAuthUrl?.let { viewModel.confirmAuthorize(it) } - pendingAuthUrl = null - }, - onFailure = { - showBiometrics = false - pendingAuthUrl = null - }, - ) - } + if (showBiometrics) { + BiometricsView( + onSuccess = { + showBiometrics = false + pendingAuthUrl?.let { viewModel.confirmAuthorize(it) } + pendingAuthUrl = null + }, + onFailure = { + showBiometrics = false + pendingAuthUrl?.let(viewModel::cancelLocalAuth) + pendingAuthUrl = null + }, + ) } } @@ -166,15 +196,22 @@ internal fun resolvePubkyApprovalLocalAuthMode( @Composable private fun Content( uiState: PubkyAuthApprovalUiState, + isCurrentRequest: Boolean, onAuthorize: () -> Unit, + onApproveWatchOnly: () -> Unit, + onBackToWatchOnly: () -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, ) { - val headerTitle = if (uiState.state == ApprovalState.Success) { - stringResource(R.string.profile__auth_approval_success) - } else { - stringResource(R.string.profile__auth_approval_title) - } + val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading + val headerTitle = approvalHeaderTitle(approvalState) + val onBack = approvalBackAction( + approvalState = approvalState, + bitkitClaim = uiState.bitkitClaim, + onBackToWatchOnly = onBackToWatchOnly, + onCancel = onCancel, + onDismiss = onDismiss, + ) Column( modifier = Modifier @@ -183,16 +220,20 @@ private fun Content( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - SheetTopBar(titleText = headerTitle) + SheetTopBar(titleText = headerTitle, onBack = onBack) - when (uiState.state) { + when (approvalState) { ApprovalState.Loading -> LoadingContent() + ApprovalState.WatchOnlyConsent -> WatchOnlyConsentContent( + onApprove = onApproveWatchOnly, + onCancel = onCancel, + ) ApprovalState.Authorize -> AuthorizeContent( uiState = uiState, onAuthorize = onAuthorize, onCancel = onCancel, ) - ApprovalState.Authorizing -> AuthorizingContent( + ApprovalState.Authenticating, ApprovalState.Authorizing -> AuthorizingContent( uiState = uiState, ) ApprovalState.Success -> SuccessContent( @@ -203,6 +244,81 @@ private fun Content( } } +@Composable +private fun approvalHeaderTitle(approvalState: ApprovalState): String = when (approvalState) { + ApprovalState.WatchOnlyConsent -> stringResource(R.string.profile__auth_approval_watch_only_intro_nav_title) + ApprovalState.Success -> stringResource(R.string.profile__auth_approval_success) + else -> stringResource(R.string.profile__auth_approval_title) +} + +private fun approvalBackAction( + approvalState: ApprovalState, + bitkitClaim: PubkyAuthClaim?, + onBackToWatchOnly: () -> Unit, + onCancel: () -> Unit, + onDismiss: () -> Unit, +): (() -> Unit)? = when (approvalState) { + ApprovalState.Authorize if bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> onBackToWatchOnly + ApprovalState.Authorize, ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel + ApprovalState.Success -> onDismiss + else -> null +} + +@Composable +private fun ColumnScope.WatchOnlyConsentContent( + onApprove: () -> Unit, + onCancel: () -> Unit, +) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .testTag("PubkyAuthWatchOnlyConsent") + ) { + FillHeight(min = 26.dp) + + Image( + painter = painterResource(R.drawable.coin_stack), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + + VerticalSpacer(36.dp) + + Display( + text = stringResource(R.string.profile__auth_approval_watch_only_intro_title) + .withAccent(accentColor = Colors.Blue), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource(R.string.profile__auth_approval_watch_only_intro_description), + color = Colors.White64, + ) + + VerticalSpacer(32.dp) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier + .weight(1f) + .testTag("PubkyAuthWatchOnlyCancel"), + ) + PrimaryButton( + text = stringResource(R.string.profile__auth_approval_watch_only_intro_approve), + onClick = onApprove, + modifier = Modifier + .weight(1f) + .testTag("PubkyAuthWatchOnlyApprove"), + ) + } + VerticalSpacer(16.dp) + } +} + @Composable private fun ColumnScope.LoadingContent() { FillHeight() @@ -221,19 +337,7 @@ private fun ColumnScope.AuthorizeContent( onAuthorize: () -> Unit, onCancel: () -> Unit, ) { - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(32.dp) - - PermissionsSection(permissions = uiState.permissions) - VerticalSpacer(16.dp) - - FillHeight() - - TrustWarning() - VerticalSpacer(16.dp) - - uiState.profile?.let { ProfileCard(it) } - VerticalSpacer(24.dp) + ApprovalDetails(uiState = uiState) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( @@ -254,27 +358,38 @@ private fun ColumnScope.AuthorizeContent( private fun ColumnScope.AuthorizingContent( uiState: PubkyAuthApprovalUiState, ) { - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(32.dp) + ApprovalDetails(uiState = uiState) - PermissionsSection(permissions = uiState.permissions) + BodyMSB( + text = stringResource(R.string.profile__auth_approval_authorizing), + color = Colors.White32, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp), + ) VerticalSpacer(16.dp) +} - FillHeight() +@Composable +private fun ColumnScope.ApprovalDetails( + uiState: PubkyAuthApprovalUiState, +) { + Column(modifier = Modifier.weight(1f)) { + VerticalSpacer(26.dp) - TrustWarning() - VerticalSpacer(16.dp) + DescriptionText(serviceName = uiState.serviceName) + VerticalSpacer(32.dp) - uiState.profile?.let { ProfileCard(it) } - VerticalSpacer(24.dp) + PermissionsSection(permissions = uiState.permissions) + FillHeight(min = 32.dp) - PrimaryButton( - text = stringResource(R.string.profile__auth_approval_authorizing), - onClick = {}, - isLoading = true, - enabled = false, - ) - VerticalSpacer(16.dp) + TrustWarning() + VerticalSpacer(16.dp) + + uiState.profile?.let { ProfileCard(it) } + VerticalSpacer(16.dp) + } } @Composable @@ -282,9 +397,11 @@ private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, onDismiss: () -> Unit, ) { + VerticalSpacer(26.dp) + SuccessDescriptionText( serviceName = uiState.serviceName, - truncatedKey = uiState.profile?.truncatedPublicKey ?: "", + truncatedKey = uiState.profile?.authDisplayPublicKey.orEmpty(), ) VerticalSpacer(16.dp) @@ -356,7 +473,7 @@ private fun PermissionRow(permission: PubkyAuthPermission) { ) HorizontalSpacer(4.dp) BodySSB( - text = permission.path, + text = permission.displayPath, modifier = Modifier.weight(1f), ) Text13Up( @@ -383,15 +500,70 @@ private fun ProfileCard(profile: PubkyProfile) { .background(Colors.Gray6, RoundedCornerShape(16.dp)) .padding(24.dp), ) { - CenteredProfileHeader( - publicKey = profile.publicKey, - name = profile.name, - bio = "", - imageUrl = profile.imageUrl, + Text13Up( + text = profile.authDisplayPublicKey, + color = Colors.White64, + ) + VerticalSpacer(16.dp) + + if (profile.imageUrl != null) { + PubkyImage(uri = profile.imageUrl, size = 96.dp) + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(Colors.Gray5), + ) { + Icon( + painter = painterResource(R.drawable.ic_user_square), + contentDescription = null, + tint = Colors.White32, + modifier = Modifier.size(48.dp), + ) + } + } + + VerticalSpacer(16.dp) + Headline( + text = AnnotatedString(profile.name), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) } } +private val PubkyProfile.authDisplayPublicKey: String + get() = pubkyAuthDisplayPublicKey(publicKey) + +internal fun pubkyAuthDisplayPublicKey(publicKey: String): String { + val rawKey = publicKey.removePrefix("pubky") + return if (rawKey.length > 8) "${rawKey.take(4)}...${rawKey.takeLast(4)}" else rawKey +} + +@Preview(showSystemUi = true) +@Composable +private fun WatchOnlyConsentPreview() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = PubkyAuthApprovalUiState( + state = ApprovalState.WatchOnlyConsent, + serviceName = "paykit", + bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + isCurrentRequest = true, + onAuthorize = {}, + onApproveWatchOnly = {}, + onBackToWatchOnly = {}, + onCancel = {}, + onDismiss = {}, + ) + } + } +} + @Preview(showSystemUi = true) @Composable private fun AuthorizePreview() { @@ -405,6 +577,7 @@ private fun AuthorizePreview() { PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"), PubkyAuthPermission(path = "/pub/paykit/v0/", accessLevel = "rw"), ), + bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, profile = PubkyProfile( publicKey = "pk8e3qm5f4kgczagxhertyuiop1gxag", name = "Satoshi Nakamoto", @@ -414,7 +587,10 @@ private fun AuthorizePreview() { status = null, ), ), + isCurrentRequest = true, onAuthorize = {}, + onApproveWatchOnly = {}, + onBackToWatchOnly = {}, onCancel = {}, onDismiss = {}, ) @@ -432,7 +608,10 @@ private fun SuccessPreview() { state = ApprovalState.Success, serviceName = "pubky.app", ), + isCurrentRequest = true, onAuthorize = {}, + onApproveWatchOnly = {}, + onBackToWatchOnly = {}, onCancel = {}, onDismiss = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 0af0750034..702d8abc61 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList @@ -16,19 +17,27 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthPermission import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.utils.Logger +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @HiltViewModel class PubkyAuthApprovalViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, ) : ViewModel() { companion object { private const val TAG = "PubkyAuthApprovalVM" @@ -40,31 +49,49 @@ class PubkyAuthApprovalViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private val inFlightAuthorization = AtomicReference() fun load(authUrl: String) { + inFlightAuthorization.get()?.takeIf { it.authUrl == authUrl }?.let { authorization -> + if (_uiState.value.authUrl != authUrl) { + authorization.uiState?.let { authorizingState -> + _uiState.update { authorizingState } + } + } + return + } + if (!resetForLoad(authUrl)) return viewModelScope.launch { - val details = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + if (_uiState.value.authUrl != authUrl) return@launch Logger.error("Failed to parse auth request", it, context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__auth_error_title), - description = it.message, + description = it.localizedPubkyAuthMessage(context), ) _effects.emit(PubkyAuthApprovalEffect.Dismiss) return@launch } - val caps = details.capabilities.orEmpty() - val permissions = PubkyAuthRequest.parseCapabilities(caps) - val serviceNames = permissions.mapNotNull { PubkyAuthRequest.extractServiceName(it.path) }.distinct() + if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) - val serviceName = serviceNames.firstOrNull() ?: unknownService - val profile = pubkyRepo.profile.value - + val serviceName = request.serviceNames.firstOrNull() ?: unknownService + val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> + PubkyProfile.forDisplay( + publicKey = publicKey, + name = pubkyRepo.displayName.value, + imageUrl = pubkyRepo.displayImageUri.value, + ) + } _uiState.update { it.copy( - state = ApprovalState.Authorize, + state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + ApprovalState.WatchOnlyConsent + } else { + ApprovalState.Authorize + }, serviceName = serviceName, - requestedCapabilities = caps, - permissions = permissions.toImmutableList(), + permissions = request.permissions.toImmutableList(), + bitkitClaim = request.bitkitClaim, profile = profile, ) } @@ -72,44 +99,194 @@ class PubkyAuthApprovalViewModel @Inject constructor( } fun requestAuthorize(authUrl: String) { + val state = _uiState.value + if (state.authUrl != authUrl || state.state != ApprovalState.Authorize) return + if (!_uiState.compareAndSet(state, state.copy(state = ApprovalState.Authenticating))) return viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl)) } } + fun approveWatchOnlyConsent(authUrl: String) { + _uiState.update { state -> + if (state.authUrl == authUrl && state.state == ApprovalState.WatchOnlyConsent) { + state.copy(state = ApprovalState.Authorize) + } else { + state + } + } + } + + fun returnToWatchOnlyConsent(authUrl: String) { + _uiState.update { state -> + if ( + state.authUrl == authUrl && + state.state == ApprovalState.Authorize && + state.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 + ) { + state.copy(state = ApprovalState.WatchOnlyConsent) + } else { + state + } + } + } + + fun cancelLocalAuth(authUrl: String) { + _uiState.update { state -> + if (state.authUrl == authUrl && state.state == ApprovalState.Authenticating) { + state.copy(state = ApprovalState.Authorize) + } else { + state + } + } + } + fun confirmAuthorize(authUrl: String) { + val authorization = InFlightAuthorization(authUrl) + if (!inFlightAuthorization.compareAndSet(null, authorization)) return + val authorizingState = transitionToAuthorizing(authUrl) + if (authorizingState == null) { + inFlightAuthorization.compareAndSet(authorization, null) + return + } + authorization.uiState = authorizingState + viewModelScope.launch { - _uiState.update { it.copy(state = ApprovalState.Authorizing) } - val capabilities = _uiState.value.requestedCapabilities.ifBlank { - pubkyRepo.parseAuthUrl(authUrl).getOrElse { - Logger.error("Failed to parse auth request", it, context = TAG) - _uiState.update { state -> state.copy(state = ApprovalState.Authorize) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) - return@launch - }.capabilities.orEmpty() + try { + authorize(authUrl) + } finally { + inFlightAuthorization.compareAndSet(authorization, null) + } + } + } + + private suspend fun authorize(authUrl: String) { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + handleApprovalFailure(it, authUrl) + return + } + if (_uiState.value.authUrl != authUrl) return + if (!approveRequest(request, authUrl)) return + + Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) + _uiState.update { state -> + if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state + } + } + + private suspend fun approveRequest( + request: PubkyAuthRequest, + authUrl: String, + ): Boolean { + val preparedClaim = runSuspendCatching { + if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) + } else { + null } + }.getOrElse { + handleApprovalFailure(it, authUrl) + return false + } - pubkyRepo.approveAuth(authUrl, capabilities) - .onSuccess { - Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG) - _uiState.update { it.copy(state = ApprovalState.Success) } + var preserveAuthorizingState = preparedClaim?.account?.setupState == WatchOnlyAccountSetupState.Authorizing + preparedClaim?.let { claim -> + runSuspendCatching { watchOnlyAccountRepo.beginAuthorization(claim.account.id) } + .onSuccess { preserveAuthorizingState = it } + .getOrElse { + if (it is WatchOnlyAccountAuthorizationStartError) { + preserveAuthorizingState = it.preserveAuthorizingState + } + cancelIncompleteSetup(claim.account.id, preserveAuthorizingState) + handleApprovalFailure(it, authUrl) + return false } - .onFailure { - Logger.error("Auth approval failed", it, context = TAG) - _uiState.update { it.copy(state = ApprovalState.Authorize) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, + } + + val approvalResult = preparedClaim?.let { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) + } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) + if (approvalResult.isFailure) { + val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } + preparedClaim?.let { claim -> + if (!approvalError.isPostDeliveryAuthorizationFailure()) { + cancelIncompleteSetup( + claim.account.id, + preserveAuthorizingState, ) } + } + handleApprovalFailure(approvalError, authUrl) + return false + } + + preparedClaim?.let { claim -> + runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { + handleApprovalFailure(it, authUrl) + return false + } + } + return true + } + + private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { + val initialState = _uiState.value + if ( + initialState.authUrl != authUrl || + (initialState.state != ApprovalState.Authorize && initialState.state != ApprovalState.Authenticating) + ) { + return null + } + val authorizingState = initialState.copy(state = ApprovalState.Authorizing) + return authorizingState.takeIf { _uiState.compareAndSet(initialState, it) } + } + + private fun resetForLoad(authUrl: String): Boolean { + while (true) { + val currentState = _uiState.value + if ( + currentState.authUrl == authUrl && + currentState.state in setOf(ApprovalState.Authenticating, ApprovalState.Authorizing) + ) { + return false + } + if (_uiState.compareAndSet(currentState, PubkyAuthApprovalUiState(authUrl = authUrl))) return true } } + private suspend fun cancelIncompleteSetup( + accountId: String, + preserveAuthorizingState: Boolean, + ) { + runSuspendCatching { + watchOnlyAccountRepo.cancelAuthorization(accountId, preserveAuthorizingState) + } + .onFailure { + Logger.error( + "Failed to unload incomplete watch-only account", + it, + context = TAG, + ) + } + } + + private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) { + Logger.error("Auth approval failed", error, context = TAG) + if (_uiState.value.authUrl != authUrl) return + _uiState.update { it.copy(state = ApprovalState.Authorize) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = error.localizedPubkyAuthMessage(context), + ) + } + + private fun defaultWatchOnlyAccountName(request: PubkyAuthRequest): String { + val serviceName = request.serviceNames.firstOrNull() + ?: context.getString(R.string.profile__auth_approval_service_unknown) + return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) + } + fun dismiss() { viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) } } @@ -117,16 +294,19 @@ class PubkyAuthApprovalViewModel @Inject constructor( @Stable data class PubkyAuthApprovalUiState( + val authUrl: String = "", val state: ApprovalState = ApprovalState.Loading, val serviceName: String = "", - val requestedCapabilities: String = "", val permissions: ImmutableList = persistentListOf(), + val bitkitClaim: PubkyAuthClaim? = null, val profile: PubkyProfile? = null, ) sealed interface ApprovalState { data object Loading : ApprovalState + data object WatchOnlyConsent : ApprovalState data object Authorize : ApprovalState + data object Authenticating : ApprovalState data object Authorizing : ApprovalState data object Success : ApprovalState } @@ -135,3 +315,19 @@ sealed interface PubkyAuthApprovalEffect { data class RequestLocalAuth(val authUrl: String) : PubkyAuthApprovalEffect data object Dismiss : PubkyAuthApprovalEffect } + +private class InFlightAuthorization( + val authUrl: String, +) { + @Volatile + var uiState: PubkyAuthApprovalUiState? = null +} + +private fun Throwable.isPostDeliveryAuthorizationFailure(): Boolean { + var current: Throwable? = this + while (current != null) { + if (current is PubkyAuthCompanionClaimApprovalException.AuthorizationFailure) return true + current = current.cause + } + return false +} diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index c371ed01c1..d838f61b93 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -14,6 +14,7 @@ import to.bitkit.models.ElectrumServer import to.bitkit.models.addressTypeInfo import to.bitkit.models.toAddressType import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WatchOnlyAccountRepo import javax.inject.Inject private const val NODE_ID_PREFIX_LENGTH = 5 @@ -23,6 +24,7 @@ private const val ELECTRUM_HOST_PREFIX_LENGTH = 5 class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, + watchOnlyAccountRepo: WatchOnlyAccountRepo, ) : ViewModel() { val selectedAddressTypeName = settingsStore.data @@ -33,6 +35,9 @@ class AdvancedSettingsViewModel @Inject constructor( .map { it.channels.filterOpen().size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + val watchOnlyAccountCount = watchOnlyAccountRepo.currentWalletAccountCount + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + val truncatedNodeId = lightningRepo.lightningState .map { it.nodeId.take(NODE_ID_PREFIX_LENGTH).ifEmpty { "" } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index 11e708c96d..6ea1d56298 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -115,6 +115,7 @@ fun SettingsScreen( val truncatedNodeId by advancedViewModel.truncatedNodeId.collectAsStateWithLifecycle() val electrumHost by advancedViewModel.electrumHost.collectAsStateWithLifecycle() val coinSelectAuto by advancedViewModel.coinSelectAuto.collectAsStateWithLifecycle() + val watchOnlyAccountCount by advancedViewModel.watchOnlyAccountCount.collectAsStateWithLifecycle() LaunchedEffect(Unit) { languageViewModel.fetchLanguageInfo() } @@ -152,6 +153,7 @@ fun SettingsScreen( openChannelCount = openChannelCount, truncatedNodeId = truncatedNodeId, electrumHost = electrumHost, + watchOnlyAccountCount = watchOnlyAccountCount, ), onEvent = { event -> when (event) { @@ -195,6 +197,7 @@ fun SettingsScreen( SettingsEvent.AddressTypeClick -> navController.navigateTo(Routes.AddressTypePreference) SettingsEvent.CoinSelectionClick -> navController.navigateTo(Routes.CoinSelectPreference) SettingsEvent.AddressViewerClick -> navController.navigateTo(Routes.AddressViewer) + SettingsEvent.WatchOnlyAccountsClick -> navController.navigateTo(Routes.WatchOnlyAccounts) SettingsEvent.LightningConnectionsClick -> navController.navigateTo(Routes.LightningConnections) SettingsEvent.LightningNodeClick -> navController.navigateTo(Routes.NodeInfo) SettingsEvent.ElectrumServerClick -> navController.navigateTo(Routes.ElectrumConfig) @@ -557,6 +560,13 @@ private fun AdvancedTabContent( onClick = { onEvent(SettingsEvent.AddressViewerClick) }, modifier = Modifier.testTag("AddressViewer") ) + SettingsButtonRow( + title = stringResource(R.string.watch_only_accounts__title), + icon = { SettingsIcon(R.drawable.ic_lock_key) }, + value = SettingsButtonValue.StringValue(state.watchOnlyAccountCount.toString()), + onClick = { onEvent(SettingsEvent.WatchOnlyAccountsClick) }, + modifier = Modifier.testTag("WatchOnlyAccounts") + ) SectionHeader( title = stringResource(R.string.settings__adv__section_networks), @@ -682,6 +692,7 @@ sealed interface SettingsEvent { data object AddressTypeClick : SettingsEvent data object CoinSelectionClick : SettingsEvent data object AddressViewerClick : SettingsEvent + data object WatchOnlyAccountsClick : SettingsEvent data object LightningConnectionsClick : SettingsEvent data object LightningNodeClick : SettingsEvent data object ElectrumServerClick : SettingsEvent @@ -729,4 +740,5 @@ data class AdvancedTabState( val openChannelCount: Int = 0, val truncatedNodeId: String = "", val electrumHost: String = "", + val watchOnlyAccountCount: Int = 0, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt new file mode 100644 index 0000000000..247e35a2f5 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt @@ -0,0 +1,286 @@ +package to.bitkit.ui.settings.advanced + +import androidx.compose.foundation.layout.Arrangement +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.ui.appViewModel +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheet +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.settings.SectionHeader +import to.bitkit.ui.components.settings.SettingsButtonRow +import to.bitkit.ui.components.settings.SettingsSwitchRow +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard + +@Composable +fun WatchOnlyAccountsScreen( + navController: NavController, + viewModel: WatchOnlyAccountsViewModel = hiltViewModel(), +) { + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val isUpdating by viewModel.isUpdating.collectAsStateWithLifecycle() + + Content( + accounts = accounts, + isUpdating = isUpdating, + onBack = { navController.popBackStack() }, + onRename = viewModel::rename, + onTrackingChange = viewModel::setTrackingEnabled, + ) +} + +@Composable +private fun Content( + accounts: ImmutableList, + isUpdating: Boolean, + onBack: () -> Unit, + onRename: (WatchOnlyAccountRecord, String) -> Unit, + onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, +) { + val activeAccounts = accounts.filter { it.setupState == WatchOnlyAccountSetupState.Active } + val pendingAccounts = accounts.filter { it.setupState != WatchOnlyAccountSetupState.Active } + var selectedAccount by remember { mutableStateOf(null) } + + ScreenColumn(modifier = Modifier.testTag("WatchOnlyAccountsScreen")) { + AppTopBar( + titleText = stringResource(R.string.watch_only_accounts__title), + onBackClick = onBack, + ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + item { + BodyM( + text = stringResource(R.string.watch_only_accounts__description), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + } + if (accounts.isEmpty()) { + item { EmptyState() } + } else { + if (activeAccounts.isNotEmpty()) { + item { + SectionHeader(stringResource(R.string.watch_only_accounts__active_section)) + } + items(activeAccounts, key = WatchOnlyAccountRecord::id) { account -> + ActiveAccountRows( + account = account, + isUpdating = isUpdating, + onOpenDetails = { selectedAccount = account }, + onTrackingChange = onTrackingChange, + ) + } + } + + if (pendingAccounts.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(R.string.watch_only_accounts__pending_section), + color = Colors.Yellow, + ) + BodyM( + text = stringResource(R.string.watch_only_accounts__pending_description), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + } + items(pendingAccounts, key = WatchOnlyAccountRecord::id) { account -> + PendingAccountRow( + account = account, + onOpenDetails = { selectedAccount = account }, + ) + } + } + } + item { VerticalSpacer(32.dp) } + } + } + + selectedAccount?.let { account -> + AccountDetailsSheet( + account = account, + onRename = { name -> onRename(account, name) }, + onDismiss = { selectedAccount = null }, + ) + } +} + +@Composable +private fun ActiveAccountRows( + account: WatchOnlyAccountRecord, + isUpdating: Boolean, + onOpenDetails: () -> Unit, + onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, +) { + Column(modifier = Modifier.testTag("WatchOnlyAccount_${account.accountIndex}")) { + SettingsButtonRow( + title = account.name, + subtitle = account.derivationPath, + onClick = onOpenDetails, + ) + SettingsSwitchRow( + title = stringResource(R.string.watch_only_accounts__tracking), + isChecked = account.isTrackingEnabled, + onClick = { onTrackingChange(account, !account.isTrackingEnabled) }, + enabled = !isUpdating, + switchTestTag = "WatchOnlyAccountTracking_${account.accountIndex}", + ) + VerticalSpacer(8.dp) + } +} + +@Composable +private fun PendingAccountRow( + account: WatchOnlyAccountRecord, + onOpenDetails: () -> Unit, +) { + SettingsButtonRow( + title = account.name, + subtitle = account.derivationPath, + description = stringResource(R.string.watch_only_accounts__setup_not_confirmed), + onClick = onOpenDetails, + modifier = Modifier.testTag("WatchOnlyAccount_${account.accountIndex}"), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AccountDetailsSheet( + account: WatchOnlyAccountRecord, + onRename: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember(account.id, account.name) { mutableStateOf(account.name) } + val context = LocalContext.current + val app = appViewModel + val copyXpub = copyToClipboard(account.xpub) { + app?.toast( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.common__copied), + ) + } + + BottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.imePadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("WatchOnlyAccountDetails_${account.accountIndex}"), + ) { + SheetTopBar(titleText = stringResource(R.string.watch_only_accounts__details_title)) + + if (account.setupState != WatchOnlyAccountSetupState.Active) { + BodyM( + text = stringResource(R.string.watch_only_accounts__setup_not_finished), + color = Colors.Yellow, + modifier = Modifier.testTag("WatchOnlyAccountPending_${account.accountIndex}"), + ) + VerticalSpacer(24.dp) + } + + Caption13Up(stringResource(R.string.watch_only_accounts__name), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = name, + onValueChange = { name = it }, + placeholder = stringResource(R.string.watch_only_accounts__name_placeholder), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag("WatchOnlyAccountName_${account.accountIndex}"), + ) + + VerticalSpacer(24.dp) + Caption13Up(stringResource(R.string.watch_only_accounts__xpub), color = Colors.White64) + VerticalSpacer(8.dp) + BodyM( + text = account.xpub, + color = Colors.White64, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("WatchOnlyAccountXpub_${account.accountIndex}"), + ) + + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SecondaryButton( + text = stringResource(R.string.watch_only_accounts__save_name), + onClick = { onRename(name) }, + size = ButtonSize.Small, + modifier = Modifier + .weight(1f) + .testTag("WatchOnlyAccountSaveName_${account.accountIndex}"), + ) + SecondaryButton( + text = stringResource(R.string.watch_only_accounts__copy_xpub), + onClick = copyXpub, + size = ButtonSize.Small, + modifier = Modifier + .weight(1f) + .testTag("WatchOnlyAccountCopyXpub_${account.accountIndex}"), + ) + } + VerticalSpacer(24.dp) + } + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .testTag("WatchOnlyAccountsEmpty"), + ) { + BodySSB(stringResource(R.string.watch_only_accounts__empty_title)) + VerticalSpacer(8.dp) + BodyM( + text = stringResource(R.string.watch_only_accounts__empty_description), + color = Colors.White64, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt new file mode 100644 index 0000000000..09d8f925dc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt @@ -0,0 +1,82 @@ +package to.bitkit.ui.settings.advanced + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.Toast +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.repositories.WatchOnlyAccountRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.localizedPubkyAuthMessage +import javax.inject.Inject + +@HiltViewModel +class WatchOnlyAccountsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, +) : ViewModel() { + private val _isUpdating = MutableStateFlow(false) + val isUpdating = _isUpdating.asStateFlow() + + val accounts = watchOnlyAccountRepo.currentWalletAccounts + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) + + fun rename(account: WatchOnlyAccountRecord, name: String) { + viewModelScope.launch { + runSuspendCatching { watchOnlyAccountRepo.rename(account.id, name) } + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.watch_only_accounts__name_saved), + ) + } + .onFailure { showError(it) } + } + } + + fun setTrackingEnabled(account: WatchOnlyAccountRecord, enabled: Boolean) { + viewModelScope.launch { + _isUpdating.update { true } + try { + runSuspendCatching { + watchOnlyAccountRepo.setTrackingEnabled(account.id, enabled) + }.onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString( + if (enabled) { + R.string.watch_only_accounts__tracking_enabled + } else { + R.string.watch_only_accounts__tracking_disabled + } + ), + ) + }.onFailure { error -> showError(error) } + } finally { + _isUpdating.update { false } + } + } + } + + private suspend fun showError(error: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.localizedPubkyAuthMessage(context), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt new file mode 100644 index 0000000000..f683c6335d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -0,0 +1,29 @@ +package to.bitkit.ui.utils + +import android.content.Context +import to.bitkit.R +import to.bitkit.models.PubkyAuthRequestError +import to.bitkit.repositories.WatchOnlyAccountError + +fun Throwable.localizedPubkyAuthMessage(context: Context): String? { + var current: Throwable? = this + while (current != null) { + val messageResource = when (current) { + is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url + PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim + PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim + is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim + PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities + WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished + WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name + WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub + WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable + else -> null + } + if (messageResource != null) { + return context.getString(messageResource) + } + current = current.cause + } + return message +} diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 3b23c2092f..ce9e32170e 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -15,6 +15,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.utils.Logger @@ -31,6 +32,7 @@ class WipeWalletUseCase @Inject constructor( private val db: AppDb, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, @@ -66,6 +68,7 @@ class WipeWalletUseCase @Inject constructor( settingsStore.reset() cacheStore.reset() + watchOnlyAccountRepo.clear() widgetsStore.reset() blocktankRepo.resetState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83f888e..e205f76fb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,7 +574,17 @@ You authorized with pubky <accent>%1$s</accent> and gave the service permission to access and edit your <accent>%2$s</accent> data. Authorize Make sure you trust the service, browser, or device before authorizing with your pubky. + %1$s server + Approve + To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds. + Earn + <accent>EARN BITCOIN</accent>\nFROM YOUR\nCONTENT + This authorization contains more than one Bitkit claim. + The requested access does not match this Bitkit claim. + This Pubky authorization link is invalid. + This authorization is missing its required Bitkit claim. Authorization Failed + This Bitkit claim is not supported. Failed to read selected image Create profile with Bitkit Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring. @@ -1245,6 +1255,28 @@ Transfer To Savings Transfer To Spending Your withdrawal was unsuccessful. Please scan the QR code again or contact support. + Active accounts + Copy xpub + Each approved service gets a separate Bitcoin account. Turn tracking off to unload an account without deleting its wallet history or revoking the service session. + Account details + Accounts you approve for Paykit servers will appear here. + No server accounts + Enter an account name between 1 and 64 characters. + Bitkit could not create a valid account xpub. + The wallet must be running before an account can be created. + NAME + Account name + Account name saved + These accounts were created locally, but setup did not finish. Retry the same authorization to reuse the account. + Incomplete setup + Save name + Setup not confirmed + Setup did not finish. Retry the same authorization to use this account. + Server accounts + Track account + Account tracking disabled + Account tracking enabled + EXTENDED PUBLIC KEY Add Widget Data (max 4) Examine various statistics on newly mined Bitcoin Blocks. Powered by mempool.space. diff --git a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt new file mode 100644 index 0000000000..b8718d83bf --- /dev/null +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -0,0 +1,237 @@ +package to.bitkit.data + +import org.junit.Test +import to.bitkit.di.json +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchOnlyAccountStoreTest { + private companion object { + const val TEST_XPUB = + "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" + + "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP" + } + + @Test + fun `allocation is monotonic and retries reuse their reservation`() { + var data = WatchOnlyAccountData() + + fun reserve(requestFingerprint: String): Int { + val reservation = data.reserveAccountIndex(walletIndex = 0, requestFingerprint) + data = reservation.data + return reservation.accountIndex + } + + assertEquals(1, reserve("first")) + assertEquals(1, reserve("first")) + assertEquals(2, reserve("second")) + assertEquals(2, data.highestAccountIndexByWallet["0"]) + } + + @Test + fun `persisted accounts restore the allocator high water mark`() { + val data = WatchOnlyAccountData(accounts = listOf(account(accountIndex = 7))) + val reservation = data.reserveAccountIndex(walletIndex = 0, requestFingerprint = "next") + + assertEquals(8, reservation.accountIndex) + assertEquals(7, reservation.data.accounts.single().accountIndex) + } + + @Test + fun `restoring an older backup preserves high water and clears unstored reservations`() { + val data = WatchOnlyAccountData( + highestAccountIndexByWallet = mapOf("0" to 10), + pendingAccountIndexByRequest = mapOf("0:pending" to 10), + ) + + val restored = data.restoreTestAccounts(listOf(account(accountIndex = 7))) + + assertEquals(emptyMap(), restored.pendingAccountIndexByRequest) + assertEquals(11, restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "pending").accountIndex) + } + + @Test + fun `allocator backup restores pending reuse and monotonic high water`() { + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 9), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + + val restored = WatchOnlyAccountData().restoreTestAccounts( + accounts = listOf(account(accountIndex = 5)), + allocationState = allocationState, + ) + + assertEquals(7, restored.reserveAccountIndex(0, "pending").accountIndex) + assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex) + } + + @Test + fun `restore sanitizes accounts and normalizes incomplete tracking`() { + val pending = account(accountIndex = 1).copy( + requestFingerprint = "pending", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val authorizing = account(accountIndex = 2).copy( + requestFingerprint = "authorizing", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val activeDisabled = account(accountIndex = 3).copy(isTrackingEnabled = false) + val duplicateSlot = account(accountIndex = 1).copy( + id = "duplicate", + requestFingerprint = "duplicate", + ) + val invalid = account(accountIndex = 4).copy(xpub = "invalid") + + val restored = WatchOnlyAccountData().restoreTestAccounts( + listOf(pending, authorizing, activeDisabled, duplicateSlot, invalid), + ) + + assertEquals(listOf(pending.id, authorizing.id, activeDisabled.id), restored.accounts.map { it.id }) + assertEquals(listOf(false, true, false), restored.accounts.map { it.isTrackingEnabled }) + } + + @Test + fun `restore drops unusable accounts and burns their indexes`() { + val valid = account(accountIndex = 1) + val invalidAddressType = account(accountIndex = 7).copy(addressType = "legacy") + val invalidXpub = account(accountIndex = 8).copy(xpub = "not-an-xpub") + val accountZero = account(accountIndex = 0) + + val restored = WatchOnlyAccountData().restoreTestAccounts( + listOf(invalidXpub, accountZero, invalidAddressType, valid), + ) + + assertEquals(listOf(valid), restored.accounts) + assertEquals(9, restored.reserveAccountIndex(0, "next").accountIndex) + } + + @Test + fun `restore persists replaced accounts until runtime reconciliation completes`() { + val replacedAccount = account(accountIndex = 1) + val restoredAccount = account(accountIndex = 2) + + val restored = WatchOnlyAccountData(accounts = listOf(replacedAccount)) + .restoreTestAccounts(accounts = listOf(restoredAccount)) + val reloaded = json.decodeFromString(json.encodeToString(restored)) + val otherWalletPendingRemoval = account(accountIndex = 3, walletIndex = 1) + + assertEquals(listOf(restoredAccount), reloaded.accounts) + assertEquals(listOf(replacedAccount), reloaded.accountsPendingRemoval) + assertEquals( + listOf(otherWalletPendingRemoval), + reloaded.copy(accountsPendingRemoval = reloaded.accountsPendingRemoval + otherWalletPendingRemoval) + .completeReconciliation(walletIndex = 0) + .accountsPendingRemoval, + ) + } + + @Test + fun `wallet backup round trip retains allocator state`() { + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 9), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + val payload = WalletBackupV1( + createdAt = 1, + transfers = emptyList(), + watchOnlyAccounts = listOf(account(accountIndex = 5)), + watchOnlyAccountAllocationState = allocationState, + ) + + val restored = json.decodeFromString(json.encodeToString(payload)) + + assertEquals(allocationState, restored.watchOnlyAccountAllocationState) + assertEquals(5, restored.watchOnlyAccounts?.single()?.accountIndex) + } + + @Test + fun `activation updates account and completes reservation in one snapshot`() { + val pendingAccount = account(accountIndex = 7).copy( + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val requestKey = "0:${pendingAccount.requestFingerprint}" + val data = WatchOnlyAccountData( + accounts = listOf(pendingAccount), + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf(requestKey to 7, "0:other" to 8), + ) + + val activated = data.markAccountActive(pendingAccount.id) + + assertTrue(activated.accounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.Active, activated.accounts.single().setupState) + assertFalse(requestKey in activated.pendingAccountIndexByRequest) + assertEquals(8, activated.pendingAccountIndexByRequest["0:other"]) + } + + @Test + fun `restore preserves an authorizing account over backup state`() { + val authorizing = account(accountIndex = 4).copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val requestKey = "0:${authorizing.requestFingerprint}" + val restoredActive = authorizing.copy(setupState = WatchOnlyAccountSetupState.Active) + val restored = WatchOnlyAccountData( + accounts = listOf(authorizing), + pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex), + ).restoreTestAccounts(listOf(restoredActive)) + + assertEquals(listOf(authorizing), restored.accounts) + assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey]) + } + + @Test + fun `restore preserves a local owner when backup reuses its slot`() { + val local = account(accountIndex = 5) + val conflictingBackup = local.copy( + id = "restored-owner", + requestFingerprint = "restored-request", + ) + + val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreTestAccounts(listOf(conflictingBackup)) + + assertEquals(listOf(local), restored.accounts) + assertTrue(restored.accountsPendingRemoval.isEmpty()) + } + + @Test + fun `activation fails when the account is missing`() { + val error = assertFailsWith { + WatchOnlyAccountData().markAccountActive("missing") + } + + assertEquals("Watch-only account 'missing' not found", error.message) + } + + private fun account(accountIndex: Int, walletIndex: Int = 0) = WatchOnlyAccountRecord( + id = "account-$walletIndex-$accountIndex", + walletIndex = walletIndex, + accountIndex = accountIndex, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = TEST_XPUB, + requestFingerprint = "request-$walletIndex-$accountIndex", + createdAt = 1, + name = "Account $accountIndex", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Active, + ) + + private fun WatchOnlyAccountData.restoreTestAccounts( + accounts: List, + allocationState: WatchOnlyAccountAllocationState? = null, + ) = restoreAccounts(accounts, allocationState) { xpub -> + require(xpub == TEST_XPUB) + ByteArray(78) + } +} diff --git a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt new file mode 100644 index 0000000000..91ff5b6658 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt @@ -0,0 +1,17 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertIs + +class WatchOnlyAccountDataSerializerTest { + @Test + fun `malformed JSON is reported as datastore corruption`() = runTest { + val error = runCatching { + WatchOnlyAccountDataSerializer.readFrom("{not-json".byteInputStream()) + }.exceptionOrNull() + + assertIs(error) + } +} diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 6431bd2bab..db00877783 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -2,11 +2,88 @@ package to.bitkit.models import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse recognizes watch-only account claim`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + } + + @Test + fun `parse preserves normal auth without Bitkit claim`() { + val request = PubkyAuthRequest.parse( + rawUrl = authUrl("/pub/bitkit.to/:rw"), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = "/pub/bitkit.to/:rw", + ).getOrThrow() + + assertNull(request.bitkitClaim) + } + + @Test + fun `parse rejects watch-only capability without Bitkit claim`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `parse rejects duplicate Bitkit claim`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val result = PubkyAuthRequest.parse( + rawUrl = authUrl( + capabilities, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + ), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `parse rejects unknown Bitkit claim`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, "unknown-v1"), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + val error = assertIs(result.exceptionOrNull()) + assertEquals("unknown-v1", error.value) + } + + @Test + fun `parse rejects watch-only claim with other capabilities`() { + val capabilities = "/pub/paykit/v0/:rw" + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + @Test fun `parseCapabilities parses single permission`() { val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw") @@ -61,6 +138,18 @@ class PubkyAuthRequestTest { assertEquals("READ, WRITE", perm.displayAccess) } + @Test + fun `displayPath removes capability separator`() { + val perm = PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw") + assertEquals("/pub/paykit/v0/bitkit/server", perm.displayPath) + } + + @Test + fun `displayPath preserves root`() { + val perm = PubkyAuthPermission(path = "/", accessLevel = "r") + assertEquals("/", perm.displayPath) + } + @Test fun `extractServiceName extracts from pub path`() { assertEquals("bitkit.to", PubkyAuthRequest.extractServiceName("/pub/bitkit.to/")) @@ -81,4 +170,11 @@ class PubkyAuthRequestTest { PubkyAuthRequest.extractServiceName("/pub/staging.bitkit.to/profile.json"), ) } + + private fun authUrl(capabilities: String, vararg claimValues: String): String { + val claims = claimValues.joinToString(separator = "") { + "&${PubkyAuthClaim.QUERY_PARAMETER}=$it" + } + return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" + } } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 84bafb8348..7a389f388f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -13,18 +13,24 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.AppCacheData import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.WatchOnlyAccountAllocationState +import to.bitkit.data.WatchOnlyAccountBackupSnapshot +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient @@ -35,11 +41,14 @@ import to.bitkit.di.json import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import javax.inject.Provider +import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -53,6 +62,8 @@ class BackupRepoTest : BaseUnitTest() { private val vssBackupClientLdk = mock() private val settingsStore = mock() private val widgetsStore = mock() + private val watchOnlyAccountStore = mock() + private val watchOnlyAccountRepo = mock() private val blocktankRepo = mock() private val activityRepo = mock() private val pubkyRepo = mock() @@ -81,6 +92,11 @@ class BackupRepoTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever { settingsStore.update(any()) }.thenReturn(Unit) whenever(widgetsStore.data).thenReturn(widgetsData) + whenever(watchOnlyAccountStore.data).thenReturn(MutableStateFlow(WatchOnlyAccountData())) + whenever { watchOnlyAccountStore.load() }.thenReturn(emptyList()) + whenever { watchOnlyAccountStore.backupSnapshot() }.thenReturn( + WatchOnlyAccountBackupSnapshot(emptyList(), WatchOnlyAccountAllocationState()) + ) whenever { vssBackupClient.getObject(any()) }.thenReturn(Result.success(null)) whenever { vssBackupClient.putObject(any(), any()) } .thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1))) @@ -251,6 +267,56 @@ class BackupRepoTest : BaseUnitTest() { verify(settingsStore).update(any()) } + @Test + fun `wallet backup includes watch-only accounts and allocator state`() = test { + val account = watchOnlyAccount() + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + whenever { watchOnlyAccountStore.backupSnapshot() }.thenReturn( + WatchOnlyAccountBackupSnapshot(listOf(account), allocationState) + ) + val dataCaptor = argumentCaptor() + + sut.triggerBackup(BackupCategory.WALLET) + + verifyBlocking(vssBackupClient) { + putObject(eq(BackupCategory.WALLET.name), dataCaptor.capture()) + } + val payload = json.decodeFromString(dataCaptor.firstValue.decodeToString()) + assertEquals(listOf(account), payload.watchOnlyAccounts) + assertEquals(allocationState, payload.watchOnlyAccountAllocationState) + } + + @Test + fun `wallet restore restores watch-only accounts and allocator before runtime reconciliation`() = test { + val account = watchOnlyAccount() + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + stubWalletBackup( + watchOnlyAccounts = listOf(account), + watchOnlyAccountAllocationState = allocationState, + ) + var accountsWereRestored = false + whenever { watchOnlyAccountRepo.restore(listOf(account), allocationState) }.thenAnswer { + accountsWereRestored = true + Unit + } + whenever { lightningService.reconcileWatchOnlyAccounts() }.thenAnswer { + assertTrue(accountsWereRestored) + Unit + } + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(watchOnlyAccountRepo) { restore(listOf(account), allocationState) } + verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() } + } + @Test fun `full restore should fail when private Paykit reserved indexes fail to reconcile`() = test { stubWalletBackup() @@ -265,12 +331,16 @@ class BackupRepoTest : BaseUnitTest() { private fun stubWalletBackup( paykitSdkBackupState: String? = null, + watchOnlyAccounts: List? = null, + watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? = null, ) { val walletBackup = WalletBackupV1( createdAt = 123, transfers = emptyList(), privatePaykitHighestReservedReceiveIndexByAddressType = mapOf("nativeSegwit" to 5), paykitSdkBackupState = paykitSdkBackupState, + watchOnlyAccounts = watchOnlyAccounts, + watchOnlyAccountAllocationState = watchOnlyAccountAllocationState, ) whenever { vssBackupClient.getObject(BackupCategory.WALLET.name) } .thenReturn( @@ -324,6 +394,8 @@ class BackupRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, settingsStore = settingsStore, widgetsStore = widgetsStore, + watchOnlyAccountStore = watchOnlyAccountStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, blocktankRepo = blocktankRepo, activityRepo = activityRepo, pubkyRepo = pubkyRepo, @@ -337,4 +409,17 @@ class BackupRepoTest : BaseUnitTest() { ) private class BackupRepoTestError(message: String) : AppError(message) + + private fun watchOnlyAccount() = WatchOnlyAccountRecord( + id = "account-7", + walletIndex = 0, + accountIndex = 7, + addressType = "nativeSegwit", + xpub = "xpub-7", + requestFingerprint = "pending", + createdAt = 1, + name = "Server account", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d721ce3fa..33ecceb425 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -24,6 +24,7 @@ import org.lightningdevkit.ldknode.AddressTypeBalance import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PeerDetails @@ -189,6 +190,44 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `start reconciles watch-only accounts when the underlying node is already running`() = test { + sut.setInitNodeLifecycleState() + val node = mock() + val status = mock() + whenever(lightningService.node).thenReturn(node) + whenever(lightningService.status).thenReturn(status) + whenever(status.isRunning).thenReturn(true) + whenever { lightningService.startEventListener(any()) }.thenReturn(Result.success(Unit)) + + val result = sut.start(shouldRetry = false) + + assertTrue(result.isSuccess) + assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) + verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() } + verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + } + + @Test + fun `start remains running when watch-only reconciliation fails for an already running node`() = test { + sut.setInitNodeLifecycleState() + val node = mock() + val status = mock() + whenever(lightningService.node).thenReturn(node) + whenever(lightningService.status).thenReturn(status) + whenever(status.isRunning).thenReturn(true) + whenever { lightningService.reconcileWatchOnlyAccounts() } + .thenThrow(IllegalStateException("reconciliation failed")) + whenever { lightningService.startEventListener(any()) }.thenReturn(Result.success(Unit)) + + val result = sut.start(shouldRetry = false) + + assertTrue(result.isSuccess) + assertEquals(NodeLifecycleState.Running, sut.lightningState.value.nodeLifecycleState) + verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() } + verifyBlocking(lightningService, never()) { start(anyOrNull(), any()) } + } + @Test fun `stop should transition to stopped state`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index fd9cfc4ce4..7972effba9 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -29,6 +30,7 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult @@ -206,6 +208,30 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, secretKey) } } + @Test + fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test { + val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" + val secretKey = "local_secret" + val payload = ByteArray(84) { it.toByte() } + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + + val result = sut.approveAuthWithCompanionClaim(authUrl, payload) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + secretKeyHex = secretKey, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + unsignedPayload = payload, + ), + ) + } + } + @Test fun `completeAuthentication should clear session when auth is canceled after completion`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt new file mode 100644 index 0000000000..dfcee0ac5e --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -0,0 +1,63 @@ +package to.bitkit.repositories + +import org.junit.Test +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import java.nio.ByteBuffer +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class WatchOnlyAccountClaimCodecTest { + @Test + fun `unsigned claim contains exact account metadata`() { + val rawXpub = TESTNET_SERIALIZED_HEX.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val account = account(accountIndex = 42, xpub = TESTNET_TPUB) + + val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub -> + require(xpub == TESTNET_TPUB) + rawXpub + } + + assertEquals(84, payload.size) + assertEquals(WatchOnlyAccountClaimCodec.PAYLOAD_LENGTH, payload.size) + assertEquals(WatchOnlyAccountClaimCodec.VERSION, payload[0]) + assertEquals(42, ByteBuffer.wrap(payload, 1, 4).int) + assertEquals(WatchOnlyAccountClaimCodec.NATIVE_SEGWIT_ADDRESS_TYPE, payload[5]) + assertContentEquals(rawXpub, payload.copyOfRange(6, 84)) + } + + @Test + fun `unsigned claim rejects invalid Base58Check checksum`() { + val invalidXpub = TESTNET_TPUB.dropLast(1) + if (TESTNET_TPUB.last() == '1') '2' else '1' + + assertFailsWith { + WatchOnlyAccountClaimCodec.encode(account(accountIndex = 1, xpub = invalidXpub)) { + throw IllegalArgumentException("Invalid extended public key") + } + } + } + + private fun account(accountIndex: Int, xpub: String) = WatchOnlyAccountRecord( + id = "id", + walletIndex = 0, + accountIndex = accountIndex, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = xpub, + requestFingerprint = "request", + createdAt = 1, + name = "Test", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + private companion object { + const val TESTNET_TPUB = + "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" + const val TESTNET_SERIALIZED_HEX = + "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt new file mode 100644 index 0000000000..020474ac53 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt @@ -0,0 +1,192 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.data.WatchOnlyAccountAllocationState +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountReconciliationState +import to.bitkit.data.WatchOnlyAccountStore +import to.bitkit.data.WatchOnlyAccountXpubSerializer +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.services.LightningService +import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.LoggerLdk +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchOnlyAccountLifecycleCoordinatorTest : BaseUnitTest() { + @Test + fun `reconciliation cannot remove an account while authorization is being persisted`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + var storedAccounts = listOf(account) + val tracked = AtomicBoolean(false) + val loadCount = AtomicInteger(0) + val authorizationSyncStarted = CountDownLatch(1) + val allowAuthorizationSync = CountDownLatch(1) + val store = mock() + val node = mock() + val onchainPayment = mock() + val coordinator = WatchOnlyAccountLifecycleCoordinator() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { + loadCount.incrementAndGet() + storedAccounts + } + whenever(store.loadReconciliationState()).thenAnswer { + loadCount.incrementAndGet() + WatchOnlyAccountReconciliationState(storedAccounts, emptyList()) + } + whenever(store.update(any())).thenAnswer { + val transform = it.getArgument<(List) -> List>(0) + storedAccounts = transform(storedAccounts) + Unit + } + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (tracked.get()) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { tracked.set(true) }.whenever(node) + .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + doAnswer { tracked.set(false) }.whenever(node) + .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + whenever(node.syncWallets()).thenAnswer { + authorizationSyncStarted.countDown() + check(allowAuthorizationSync.await(5, TimeUnit.SECONDS)) + Unit + } + val lightningService = lightningService(store, node, coordinator) + val sut = repository(store, lightningService, coordinator) + + val authorization = launch { sut.beginAuthorization(account.id) } + assertTrue(authorizationSyncStarted.await(5, TimeUnit.SECONDS)) + + val reconciliation = launch { + lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + } + runCurrent() + + assertEquals(1, loadCount.get()) + assertTrue(reconciliation.isActive) + + allowAuthorizationSync.countDown() + authorization.join() + reconciliation.join() + + assertTrue(tracked.get()) + assertTrue(storedAccounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState) + assertEquals(2, loadCount.get()) + verify(node, never()).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + } + + @Test + fun `restore waits for in-flight reconciliation before replacing persisted accounts`() = test { + val account = account() + val restoredAccount = account.copy(name = "Restored account") + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to account.accountIndex), + ) + val reconciliationStarted = CountDownLatch(1) + val allowReconciliation = CountDownLatch(1) + val store = mock() + val node = mock() + val onchainPayment = mock() + val coordinator = WatchOnlyAccountLifecycleCoordinator() + whenever(store.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, account.accountIndex.toUInt())), + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { + reconciliationStarted.countDown() + check(allowReconciliation.await(5, TimeUnit.SECONDS)) + }.whenever(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + account.accountIndex.toUInt(), + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + val lightningService = lightningService(store, node, coordinator) + val sut = repository(store, lightningService, coordinator) + + val reconciliation = launch { + lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + } + assertTrue(reconciliationStarted.await(5, TimeUnit.SECONDS)) + + val restore = launch { sut.restore(listOf(restoredAccount), allocationState) } + runCurrent() + verify(store, never()).restore(listOf(restoredAccount), allocationState) + + allowReconciliation.countDown() + reconciliation.join() + restore.join() + + verify(store).restore(listOf(restoredAccount), allocationState) + } + + private fun lightningService( + store: WatchOnlyAccountStore, + node: Node, + coordinator: WatchOnlyAccountLifecycleCoordinator, + ) = LightningService( + bgDispatcher = testDispatcher, + keychain = mock(), + vssStoreIdProvider = mock(), + settingsStore = mock(), + watchOnlyAccountStore = store, + loggerLdk = mock(), + watchOnlyAccountLifecycleCoordinator = coordinator, + ).apply { this.node = node } + + private fun repository( + store: WatchOnlyAccountStore, + lightningService: LightningService, + coordinator: WatchOnlyAccountLifecycleCoordinator, + ) = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + coordinator, + mock(), + ) + + private fun account() = WatchOnlyAccountRecord( + id = "account-id", + walletIndex = 0, + accountIndex = 1, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = "xpub", + requestFingerprint = "request", + createdAt = 1, + name = "Creator account", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Active, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt new file mode 100644 index 0000000000..de15f212d0 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -0,0 +1,565 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import org.junit.Test +import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.WatchOnlyAccountAllocationState +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountStore +import to.bitkit.data.WatchOnlyAccountXpubSerializer +import to.bitkit.data.reserveAccountIndex +import to.bitkit.data.restoreAccounts +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.services.LightningService +import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchOnlyAccountRepoTest : BaseUnitTest() { + @Test + fun `current wallet accounts exclude records from other wallets`() = test { + val currentWalletAccount = account().copy(walletIndex = 1) + val otherWalletAccount = account().copy(id = "other-account", walletIndex = 0) + val store = mock() + val lightningService = mock() + whenever(store.data).thenReturn( + flowOf(WatchOnlyAccountData(accounts = listOf(otherWalletAccount, currentWalletAccount))), + ) + whenever(lightningService.currentWalletIndex).thenReturn(1) + val sut = repository(store, lightningService) + + assertEquals(listOf(currentWalletAccount), sut.currentWalletAccounts.first()) + assertEquals(1, sut.currentWalletAccountCount.first()) + } + + @Test + fun `authorization fails before tracking when the prepared account is missing`() = test { + val store = mock() + val lightningService = mock() + val sut = repository(store, lightningService) + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData())) + whenever(store.load()).thenReturn(emptyList()) + + assertFailsWith { + sut.beginAuthorization("missing") + } + + verify(lightningService, never()).node + verify(store, never()).update(any()) + } + + @Test + fun `activation fails before persistence when the account is missing`() = test { + val store = mock() + val lightningService = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData())) + whenever(store.load()).thenReturn(emptyList()) + val sut = repository(store, lightningService) + + assertFailsWith { + sut.markActive("missing") + } + + verify(store, never()).markActive(any()) + } + + @Test + fun `activation persistence completes after caller cancellation while waiting for lifecycle lock`() = test { + val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing) + val store = mock() + val lightningService = mock() + val coordinator = WatchOnlyAccountLifecycleCoordinator() + val lockAcquired = CompletableDeferred() + val releaseLock = CompletableDeferred() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(account)))) + whenever(store.load()).thenReturn(listOf(account)) + val sut = repository(store, lightningService, coordinator) + val lockHolder = launch { + coordinator.withLock { + lockAcquired.complete(Unit) + releaseLock.await() + } + } + lockAcquired.await() + + val activation = launch { sut.markActive(account.id) } + runCurrent() + activation.cancel() + runCurrent() + + verify(store, never()).markActive(any()) + releaseLock.complete(Unit) + lockHolder.join() + activation.join() + + verify(store).markActive(account.id) + } + + @Test + fun `retry with reordered query reuses the pending account and xpub without tracking it`() = test { + var storedAccounts = emptyList() + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.save(any())).thenAnswer { + storedAccounts = it.getArgument(0) + Unit + } + whenever(store.reserveAccountIndex(any(), any())).thenReturn(1) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u)).thenReturn(TEST_XPUB) + val sut = repository(store, lightningService) + + val first = sut.prepareUnsignedClaim( + "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=same&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1", + "Creator account", + ) + val retry = sut.prepareUnsignedClaim( + "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&secret=same&" + + "relay=https%3A%2F%2Frelay.example", + "Renamed account", + ) + + assertEquals(first.account.id, retry.account.id) + assertEquals(first.account.accountIndex, retry.account.accountIndex) + assertEquals(first.account.xpub, retry.account.xpub) + assertEquals("Renamed account", retry.account.name) + assertFalse(retry.account.isTrackingEnabled) + verify(node, times(1)).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u) + verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, TEST_XPUB) + } + + @Test + fun `restored colliding request gets a fresh account index and xpub`() = test { + val authorizingAccount = account().copy( + accountIndex = 5, + xpub = TEST_XPUB_ALTERNATE, + requestFingerprint = "current-authorizing-request", + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val restoredRequestKey = "0:$RESTORED_REQUEST_FINGERPRINT" + var storedData = WatchOnlyAccountData( + accounts = listOf(authorizingAccount), + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf( + "0:${authorizingAccount.requestFingerprint}" to 5, + ), + ).restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5), + ), + serializeXpub = { ByteArray(78) }, + ) + assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest) + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(storedData)) + whenever(store.load()).thenAnswer { storedData.accounts } + whenever(store.reserveAccountIndex(any(), any())).thenAnswer { + val reservation = storedData.reserveAccountIndex( + walletIndex = it.getArgument(0), + requestFingerprint = it.getArgument(1), + ) + storedData = reservation.data + reservation.accountIndex + } + whenever(store.save(any())).thenAnswer { + storedData = storedData.copy(accounts = it.getArgument(0)) + Unit + } + whenever(lightningService.currentWalletIndex).thenReturn(0) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB) + val sut = repository(store, lightningService) + + val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server") + + assertEquals(6, prepared.account.accountIndex) + assertEquals(TEST_XPUB, prepared.account.xpub) + assertNotEquals(authorizingAccount.xpub, prepared.account.xpub) + assertEquals(6, storedData.pendingAccountIndexByRequest[restoredRequestKey]) + assertEquals(listOf(5, 6), storedData.accounts.map(WatchOnlyAccountRecord::accountIndex)) + verify(node).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u) + verify(node, never()).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 5u) + } + + @Test + fun `older pending reservation cannot reuse a later active account index`() = test { + val activeAccount = account().copy( + accountIndex = 5, + xpub = TEST_XPUB_ALTERNATE, + requestFingerprint = RESTORED_REQUEST_FINGERPRINT, + setupState = WatchOnlyAccountSetupState.Active, + ) + val restoredRequestKey = "0:$RESTORED_REQUEST_FINGERPRINT" + var storedData = WatchOnlyAccountData( + accounts = listOf(activeAccount), + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5), + ).restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5), + ), + serializeXpub = { ByteArray(78) }, + ) + assertEquals(listOf(activeAccount), storedData.accountsPendingRemoval) + assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest) + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(storedData)) + whenever(store.load()).thenAnswer { storedData.accounts } + whenever(store.reserveAccountIndex(any(), any())).thenAnswer { + val reservation = storedData.reserveAccountIndex( + walletIndex = it.getArgument(0), + requestFingerprint = it.getArgument(1), + ) + storedData = reservation.data + reservation.accountIndex + } + whenever(store.save(any())).thenAnswer { + storedData = storedData.copy(accounts = it.getArgument(0)) + Unit + } + whenever(lightningService.currentWalletIndex).thenReturn(0) + whenever(lightningService.node).thenReturn(node) + whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB) + val sut = repository(store, lightningService) + + val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server") + + assertEquals(6, prepared.account.accountIndex) + assertEquals(TEST_XPUB, prepared.account.xpub) + assertNotEquals(activeAccount.xpub, prepared.account.xpub) + assertEquals(6, storedData.pendingAccountIndexByRequest[restoredRequestKey]) + assertEquals(listOf(6), storedData.accounts.map(WatchOnlyAccountRecord::accountIndex)) + assertEquals(listOf(5), storedData.accountsPendingRemoval.map(WatchOnlyAccountRecord::accountIndex)) + verify(node).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u) + verify(node, never()).exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 5u) + } + + @Test + fun `failed authorization unloads the pending account`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + var storedAccounts = listOf(account) + var isTracked = false + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.save(any())).thenAnswer { + storedAccounts = it.getArgument(0) + Unit + } + whenever(lightningService.node).thenReturn(node) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + doAnswer { isTracked = true }.whenever( + node + ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + val sut = repository(store, lightningService) + + sut.beginAuthorization(account.id) + sut.cancelAuthorization(account.id) + + assertFalse(storedAccounts.single().isTrackingEnabled) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 1u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + } + + @Test + fun `retry failure keeps a delivered account authorizing and tracked`() = test { + val account = account().copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + var storedAccounts = listOf(account) + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.update(any())).thenAnswer { + val transform = it.getArgument<(List) -> List>(0) + storedAccounts = transform(storedAccounts) + Unit + } + whenever(store.save(any())).thenAnswer { + storedAccounts = it.getArgument(0) + Unit + } + whenever(lightningService.node).thenReturn(node) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)), + ) + val sut = repository(store, lightningService) + + val preserveAuthorizingState = sut.beginAuthorization(account.id) + sut.cancelAuthorization(account.id, preserveAuthorizingState) + + assertTrue(preserveAuthorizingState) + assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState) + assertTrue(storedAccounts.single().isTrackingEnabled) + verify(node, never()).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + } + + @Test + fun `failed authorization keeps persisted state when unloading fails`() = test { + val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing) + var storedAccounts = listOf(account) + val unloadError = IllegalStateException("unload failed") + val store = mock() + val lightningService = mock() + val node = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(lightningService.node).thenReturn(node) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)), + ) + doThrow(unloadError).whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + val sut = repository(store, lightningService) + + val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull() + + assertSame(unloadError, error?.cause) + assertEquals(account, storedAccounts.single()) + verify(store, never()).save(any()) + } + + @Test + fun `failed authorization reloads the account when persistence fails`() = test { + val account = account().copy(setupState = WatchOnlyAccountSetupState.Authorizing) + var storedAccounts = listOf(account) + var isTracked = true + val persistenceError = IllegalStateException("persistence failed") + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.save(any())).thenThrow(persistenceError) + whenever(lightningService.node).thenReturn(node) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { isTracked = true }.whenever(node) + .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + val sut = repository(store, lightningService) + + val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull() + + assertEquals(persistenceError.message, error?.message) + assertTrue(isTracked) + assertEquals(account, storedAccounts.single()) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 1u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node).syncWallets() + } + + @Test + fun `already tracked account still pre-reveals addresses without syncing`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + var storedAccounts = listOf(account) + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.update(any())).thenAnswer { + val transform = it.getArgument<(List) -> List>(0) + storedAccounts = transform(storedAccounts) + Unit + } + whenever(lightningService.node).thenReturn(node) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + val sut = repository(store, lightningService) + + sut.beginAuthorization(account.id) + + assertTrue(storedAccounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.Authorizing, storedAccounts.single().setupState) + verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 1u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node, never()).syncWallets() + } + + @Test + fun `new account is removed when initial sync fails and original error is preserved`() = test { + val account = account().copy(isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery) + var storedAccounts = listOf(account) + var isTracked = false + val syncError = IllegalStateException("initial sync failed") + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(lightningService.node).thenReturn(node) + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { isTracked = true }.whenever(node) + .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + doAnswer { isTracked = false }.whenever(node) + .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + whenever(node.syncWallets()).thenThrow(syncError) + val sut = repository(store, lightningService) + + val error = runCatching { sut.beginAuthorization(account.id) }.exceptionOrNull() + + assertFalse(isTracked) + assertFalse(storedAccounts.single().isTrackingEnabled) + assertEquals(WatchOnlyAccountSetupState.PendingDelivery, storedAccounts.single().setupState) + assertSame(syncError, error?.cause) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 1u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + } + + @Test + fun `disable and re-enable unloads and restores the same account`() = test { + val account = account() + var storedAccounts = listOf(account) + var isTracked = true + val store = mock() + val lightningService = mock() + val node = mock() + val onchainPayment = mock() + whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = storedAccounts))) + whenever(store.load()).thenAnswer { storedAccounts } + whenever(store.save(any())).thenAnswer { + storedAccounts = it.getArgument(0) + Unit + } + whenever(lightningService.node).thenReturn(node) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.listOnchainWalletAccounts()).thenAnswer { + if (isTracked) listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) else emptyList() + } + doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { isTracked = true }.whenever( + node + ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + val sut = repository(store, lightningService) + + sut.setTrackingEnabled(account.id, enabled = false) + + assertFalse(storedAccounts.single().isTrackingEnabled) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + + sut.setTrackingEnabled(account.id, enabled = true) + + assertTrue(storedAccounts.single().isTrackingEnabled) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 1u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node, times(1)).syncWallets() + } + + private fun repository( + store: WatchOnlyAccountStore, + lightningService: LightningService, + coordinator: WatchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator(), + ): WatchOnlyAccountRepo { + val xpubSerializer = mock() + whenever(xpubSerializer.serialize(any())).thenReturn(ByteArray(78)) + return WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator, xpubSerializer) + } + + private fun account() = WatchOnlyAccountRecord( + id = "account-id", + walletIndex = 0, + accountIndex = 1, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = "xpub", + requestFingerprint = "request", + createdAt = 1, + name = "Creator account", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Active, + ) + + private companion object { + const val RESTORED_AUTH_URL = + "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=backup&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1" + const val RESTORED_REQUEST_FINGERPRINT = "vuq8iJuzlfl/Jp58+w7KMgk1BNhEA5PJumkZfUrZjoY=" + const val TEST_XPUB = + "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" + + "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP" + const val TEST_XPUB_ALTERNATE = + "tpubDCgMbrEACV32r3jqiWn685Ni6Z8vkPtVn73Pv5ZvyXkwh4iFbrpcrXXPvtJhiCvHvRvZY6dXU" + + "gKt8aZEPpo4tRpHkNC7jR9B7JVZo8kFxCz" + } +} diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt new file mode 100644 index 0000000000..c3c748c8e2 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt @@ -0,0 +1,113 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.OnchainPayment +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.data.WatchOnlyAccountAllocationState +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountReconciliationState +import to.bitkit.data.WatchOnlyAccountStore +import to.bitkit.data.WatchOnlyAccountXpubSerializer +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.restoreAccounts +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.services.LightningService +import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.LoggerLdk +import kotlin.test.assertEquals + +class WatchOnlyAccountRestoreTest : BaseUnitTest() { + @Test + fun `retry and reconciliation use the restored incomplete account`() = test { + val restoredAccount = account(id = "restored-account", accountIndex = 5, createdAt = 1) + val requestKey = "0:$REQUEST_FINGERPRINT" + val storedData = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(restoredAccount), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(requestKey to restoredAccount.accountIndex), + ), + serializeXpub = { ByteArray(78) }, + ) + val store = mock() + val node = mock() + val onchainPayment = mock() + val coordinator = WatchOnlyAccountLifecycleCoordinator() + whenever(store.data).thenReturn(flowOf(storedData)) + whenever(store.load()).thenReturn(storedData.accounts) + whenever(store.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(storedData.accounts, storedData.accountsPendingRemoval), + ) + whenever(node.listOnchainWalletAccounts()).thenReturn(emptyList()) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + val lightningService = lightningService(store, node, coordinator) + val xpubSerializer = mock() + whenever(xpubSerializer.serialize(TEST_XPUB)).thenReturn(ByteArray(78)) + val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator, xpubSerializer) + + val prepared = sut.prepareUnsignedClaim(AUTH_URL, restoredAccount.name) + lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(restoredAccount), storedData.accounts) + assertEquals(restoredAccount.id, prepared.account.id) + assertEquals(restoredAccount.accountIndex, prepared.account.accountIndex) + verify(store, never()).reserveAccountIndex(any(), any()) + verify(node, never()).exportOnchainWalletAccountXpub(any(), any()) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u, TEST_XPUB) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + } + + private fun lightningService( + store: WatchOnlyAccountStore, + node: Node, + coordinator: WatchOnlyAccountLifecycleCoordinator, + ) = LightningService( + bgDispatcher = testDispatcher, + keychain = mock(), + vssStoreIdProvider = mock(), + settingsStore = mock(), + watchOnlyAccountStore = store, + loggerLdk = mock(), + watchOnlyAccountLifecycleCoordinator = coordinator, + ).apply { this.node = node } + + private fun account(id: String, accountIndex: Int, createdAt: Long) = WatchOnlyAccountRecord( + id = id, + walletIndex = 0, + accountIndex = accountIndex, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = TEST_XPUB, + requestFingerprint = REQUEST_FINGERPRINT, + createdAt = createdAt, + name = "Restored server", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + + private companion object { + const val AUTH_URL = + "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=backup&" + + "caps=%2Fpub%2Fpaykit%2Fv0%2Fbitkit%2Fserver%2F%3Arw&x-bitkit-claim=watch-only-account-v1" + const val REQUEST_FINGERPRINT = "vuq8iJuzlfl/Jp58+w7KMgk1BNhEA5PJumkZfUrZjoY=" + const val TEST_XPUB = + "tpubDCgMbrEACV32r3jqiWn685NmYnqDkAcas1GBGh7XUVhxKFagQdpd2aY5kBMFqAFRa9NWPzCHma" + + "BEsU7YJcyjX8M8sswT3e6wq4LKCep3YaP" + } +} diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index e77bf24ae2..66c7feea66 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -2,15 +2,29 @@ package to.bitkit.services import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.AddressType import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsStore +import to.bitkit.data.WatchOnlyAccountReconciliationState +import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails +import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -18,8 +32,10 @@ class LightningServiceTest : BaseUnitTest() { private val keychain = mock() private val vssStoreIdProvider = mock() private val settingsStore = mock() + private val watchOnlyAccountStore = mock() private val loggerLdk = mock() private val node = mock() + private val watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator() private lateinit var sut: LightningService @@ -30,7 +46,9 @@ class LightningServiceTest : BaseUnitTest() { keychain = keychain, vssStoreIdProvider = vssStoreIdProvider, settingsStore = settingsStore, + watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, + watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ) sut.node = node } @@ -56,4 +74,152 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + + @Test + fun `startup config includes enabled active and authorizing accounts for current wallet`() { + val enabled = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val disabled = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = false) + val otherWallet = watchOnlyAccount(accountIndex = 3, walletIndex = 1, enabled = true) + val pending = watchOnlyAccount(accountIndex = 4, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.PendingDelivery) + val authorizing = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + + val configs = enabledOnchainWalletAccountConfigs( + records = listOf(enabled, disabled, otherWallet, pending, authorizing), + walletIndex = 0, + ) + + assertEquals(listOf(1u, 5u), configs.map { it.accountIndex }) + assertEquals(listOf(enabled.xpub, authorizing.xpub), configs.map { it.xpub }) + } + + @Test + fun `reconciliation pre-reveals index 999 for an already tracked authorizing account`() = test { + val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + val onchainPayment = mock() + val status = mock() + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(node.status()).thenReturn(status) + whenever(status.isRunning).thenReturn(true) + + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + + sut.reconcileWatchOnlyAccounts() + + verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u, account.xpub) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node).syncWallets() + } + + @Test + fun `wallet sync retries watch-only reconciliation and syncs once`() = test { + val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) + .copy(setupState = WatchOnlyAccountSetupState.Authorizing) + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + whenever(node.listOnchainWalletAccounts()).thenReturn( + listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) + ) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + + sut.sync() + + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(node, times(1)).syncWallets() + } + + @Test + fun `restore reconciliation unloads replaced account and loads restored account`() = test { + val replacedAccount = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val restoredAccount = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = true) + val trackedAccounts = mutableListOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState( + accounts = listOf(restoredAccount), + accountsPendingRemoval = listOf(replacedAccount), + ), + ) + whenever(node.listOnchainWalletAccounts()).thenAnswer { trackedAccounts.toList() } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { + trackedAccounts.remove(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + Unit + } + .whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { trackedAccounts += OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u) } + .whenever(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + + sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u)), trackedAccounts) + verify(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + verify(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + verify(watchOnlyAccountStore).completeReconciliation(walletIndex = 0) + } + + @Test + fun `failed restore reconciliation retains removals for the next retry`() = test { + val replacedAccount = watchOnlyAccount(accountIndex = 1, walletIndex = 0, enabled = true) + val restoredAccount = watchOnlyAccount(accountIndex = 2, walletIndex = 0, enabled = true) + val trackedAccounts = mutableListOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + var failAdd = true + val onchainPayment = mock() + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState( + accounts = listOf(restoredAccount), + accountsPendingRemoval = listOf(replacedAccount), + ), + ) + whenever(node.listOnchainWalletAccounts()).thenAnswer { trackedAccounts.toList() } + whenever(node.onchainPayment()).thenReturn(onchainPayment) + doAnswer { + trackedAccounts.remove(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) + Unit + } + .whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) + doAnswer { + check(!failAdd) { "add failed" } + trackedAccounts += OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u) + }.whenever(node).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u, restoredAccount.xpub) + + assertTrue(runCatching { sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) }.isFailure) + verify(watchOnlyAccountStore, never()).completeReconciliation(walletIndex = 0) + + failAdd = false + sut.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 2u)), trackedAccounts) + verify(watchOnlyAccountStore).completeReconciliation(walletIndex = 0) + } + + private fun watchOnlyAccount(accountIndex: Int, walletIndex: Int, enabled: Boolean) = WatchOnlyAccountRecord( + id = "account-$accountIndex", + walletIndex = walletIndex, + accountIndex = accountIndex, + addressType = "nativeSegwit", + xpub = "xpub-$accountIndex", + requestFingerprint = "request-$accountIndex", + createdAt = 1, + name = "Account $accountIndex", + isTrackingEnabled = enabled, + setupState = WatchOnlyAccountSetupState.Active, + ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index c28de23cab..e5229d4bb9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -1,22 +1,60 @@ package to.bitkit.ui.screens.profile import android.content.Context -import com.synonym.paykit.PubkyAuthDetails -import com.synonym.paykit.PubkyAuthRequestKind +import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.PreparedWatchOnlyAccountClaim +import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthPermission +import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyProfile +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val context: Context = mock() - private val pubkyRepo: PubkyRepo = mock() + private val profileFlow = MutableStateFlow(null) + private val publicKeyFlow = MutableStateFlow(null) + private val displayNameFlow = MutableStateFlow(null) + private val displayImageUriFlow = MutableStateFlow(null) + private val pubkyRepo: PubkyRepo = mock { + on { profile } doReturn profileFlow + on { publicKey } doReturn publicKeyFlow + on { displayName } doReturn displayNameFlow + on { displayImageUri } doReturn displayImageUriFlow + } + private val watchOnlyAccountRepo: WatchOnlyAccountRepo = mock() + + @Before + fun setUp() { + whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + } @Test fun `initial state is loading`() { @@ -26,32 +64,495 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `confirmAuthorize reparses capabilities when load has not completed`() = test { + fun `auth display public key omits pubky prefix`() { + assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg")) + assertEquals("3rsd...w5xg", pubkyAuthDisplayPublicKey("3rsd123456789w5xg")) + } + + @Test + fun `confirmAuthorize is ignored when load has not completed`() = test { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" val capabilities = "/pub/bitkit.to/:rw" + val sut = createSut() + + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Loading, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + } + + @Test + fun `confirmAuthorize ignores a stale auth URL after another request loads`() = test { + val staleAuthUrl = "pubkyauth://signin?caps=/pub/stale/:rw" + val currentAuthUrl = "pubkyauth://signin?caps=/pub/current/:rw" + whenever { pubkyRepo.parseAuthUrl(currentAuthUrl) }.thenReturn( + Result.success(authRequest(currentAuthUrl, "/pub/current/:rw")), + ) + val sut = createSut() + + sut.load(currentAuthUrl) + advanceUntilIdle() + sut.confirmAuthorize(staleAuthUrl) + advanceUntilIdle() + + assertEquals(currentAuthUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { parseAuthUrl(staleAuthUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw") } + } + + @Test + fun `confirmAuthorize reparses the current URL and fails closed when it changes`() = test { + val authUrl = "pubkyauth://signin?caps=/pub/current/:rw" + val capabilities = "/pub/current/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + Result.failure(IllegalArgumentException("request changed")), + ) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + } + + @Test + fun `ordinary authorization uses the requested capabilities`() = test { + val authUrl = "pubkyauth://signin?caps=/pub/example/:rw" + val capabilities = "/pub/example/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + ) + whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any()) } + verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } + } + + @Test + fun `load exposes watch-only account claim for approval`() = test { + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( - PubkyAuthDetails( - kind = PubkyAuthRequestKind.SIGN_IN, - capabilities = capabilities, - relayUrl = "https://httprelay.pubky.app/inbox/", - homeserverPublicKey = null, + authRequest( + authUrl = authUrl, + capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + bitkitClaim = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, ), ), ) - whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + + sut.approveWatchOnlyConsent(authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.requestAuthorize(authUrl) + runCurrent() + assertEquals(ApprovalState.Authenticating, sut.uiState.value.state) + + sut.cancelLocalAuth(authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.returnToWatchOnlyConsent(authUrl) + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) + } + + @Test + fun `watch-only authorization uses combined companion approval`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() assertEquals(ApprovalState.Success, sut.uiState.value.state) - verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { markActive(prepared.account.id) } + } + + @Test + fun `duplicate confirmations start one companion authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } + } + + @Test + fun `switching requests and reopening during companion approval does not start another authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val secondAuthUrl = "pubkyauth://signin?caps=/pub/second/:rw" + val secondCapabilities = "/pub/second/:rw" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val approvalResult = CompletableDeferred>() + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { pubkyRepo.parseAuthUrl(secondAuthUrl) }.thenReturn( + Result.success(authRequest(secondAuthUrl, secondCapabilities)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .doSuspendableAnswer { approvalResult.await() } + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + runCurrent() + assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) + + sut.load(secondAuthUrl) + advanceUntilIdle() + assertEquals(secondAuthUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.confirmAuthorize(secondAuthUrl) + runCurrent() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.load(authUrl) + sut.confirmAuthorize(authUrl) + runCurrent() + assertEquals(authUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) + approvalResult.complete(Result.success(Unit)) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, times(1)) { parseAuthUrl(secondAuthUrl) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { approveAuth(secondAuthUrl, secondCapabilities) } + verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } + } + + @Test + fun `wrapped post-delivery authorization failure keeps account authorizing for retry`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val authorizationError = PubkyAuthCompanionClaimApprovalException.AuthorizationFailure( + "AuthToken delivery failed" + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(AppError(authorizationError))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `companion delivery failure does not approve normal auth or activate account`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `retry delivery failure keeps a previously delivered account authorizing`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount().copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo) { + cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) + } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `tracking preparation failure unloads account without attempting approval`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) } + .thenThrow(IllegalStateException("Wallet sync failed")) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } + } + + @Test + fun `tracking failure uses the current authorizing disposition`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.doSuspendableAnswer { + throw WatchOnlyAccountAuthorizationStartError( + preserveAuthorizingState = true, + cause = IllegalStateException("Wallet sync failed"), + ) + } + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { + cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) + } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + } + + @Test + fun `retry after process restart reuses account and repeats authorization`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + val retryPrepared = prepared.copy( + account = prepared.account.copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ), + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success( + authRequest( + authUrl, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, + ), + ), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") } + .thenReturn(prepared, retryPrepared) + whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false, true) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) } + .thenThrow(IllegalStateException("Persistence failed")) + .thenReturn(Unit) + val initialSut = createSut() + + initialSut.load(authUrl) + advanceUntilIdle() + initialSut.approveWatchOnlyConsent(authUrl) + initialSut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, initialSut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + + val restartedSut = createSut() + restartedSut.load(authUrl) + advanceUntilIdle() + restartedSut.approveWatchOnlyConsent(authUrl) + restartedSut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, restartedSut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo, times(2)) { prepareUnsignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo, times(2)) { beginAuthorization(prepared.account.id) } + verifyBlocking(pubkyRepo, times(2)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(watchOnlyAccountRepo, times(2)) { markActive(prepared.account.id) } } private fun createSut() = PubkyAuthApprovalViewModel( context = context, pubkyRepo = pubkyRepo, + watchOnlyAccountRepo = watchOnlyAccountRepo, + ) + + private fun authRequest( + authUrl: String, + capabilities: String, + bitkitClaim: PubkyAuthClaim? = null, + ) = PubkyAuthRequest( + rawUrl = authUrl, + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), + serviceNames = listOf("paykit"), + bitkitClaim = bitkitClaim, + ) + + private fun watchOnlyAccount() = WatchOnlyAccountRecord( + id = "account-id", + walletIndex = 0, + accountIndex = 1, + addressType = "nativeSegwit", + xpub = "xpub", + requestFingerprint = "request", + createdAt = 1, + name = "paykit server", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, ) } diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 47adac7781..54ab73af9b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -23,6 +23,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PrivatePaykitAddressReservationRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest @@ -38,6 +39,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val db = mock() private val settingsStore = mock() private val cacheStore = mock() + private val watchOnlyAccountRepo = mock() private val widgetsStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() @@ -72,6 +74,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db = db, settingsStore = settingsStore, cacheStore = cacheStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, widgetsStore = widgetsStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, @@ -100,6 +103,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db, settingsStore, cacheStore, + watchOnlyAccountRepo, widgetsStore, blocktankRepo, activityRepo, @@ -121,6 +125,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() + inOrder.verify(watchOnlyAccountRepo).clear() inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() diff --git a/changelog.d/next/1084.added.md b/changelog.d/next/1084.added.md new file mode 100644 index 0000000000..6f68318587 --- /dev/null +++ b/changelog.d/next/1084.added.md @@ -0,0 +1 @@ +Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md new file mode 100644 index 0000000000..bc043e9169 --- /dev/null +++ b/docs/watch-only-account-claim-v1.md @@ -0,0 +1,51 @@ +# Bitkit watch-only account claim v1 + +This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. + +## Request + +- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. +- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. +- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. + +## Claim payload + +Bitkit serializes this exact 84-byte unsigned payload: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. + +The signature input is the byte concatenation: + +```text +UTF8("x-bitkit-claim|watch-only-account-v1|") +|| SHA256(decoded_auth_request_secret) +|| claim_bytes[0..<84] +``` + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. +- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 278d17fee6..596a7e38e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.1" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc33" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.2" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc36" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.52" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.56" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }