From 3e735c624642e1cf3f8819dbf61e0bdfacf69ca4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 14 Jul 2026 12:19:18 +0200 Subject: [PATCH 01/13] feat: add signed paykit watch-only accounts --- .../to/bitkit/data/WatchOnlyAccountStore.kt | 53 ++++ .../WatchOnlyAccountDataSerializer.kt | 26 ++ .../java/to/bitkit/models/BackupPayloads.kt | 1 + .../java/to/bitkit/models/PubkyAuthRequest.kt | 87 +++++++ .../java/to/bitkit/models/WatchOnlyAccount.kt | 40 +++ .../java/to/bitkit/repositories/BackupRepo.kt | 16 ++ .../java/to/bitkit/repositories/PubkyRepo.kt | 11 +- .../repositories/WatchOnlyAccountRepo.kt | 238 ++++++++++++++++++ .../to/bitkit/services/LightningService.kt | 34 ++- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 + .../java/to/bitkit/ui/components/TextInput.kt | 2 + .../screens/profile/PubkyAuthApprovalSheet.kt | 77 ++++++ .../profile/PubkyAuthApprovalViewModel.kt | 77 ++++-- .../ui/settings/AdvancedSettingsViewModel.kt | 10 + .../to/bitkit/ui/settings/SettingsScreen.kt | 12 + .../advanced/WatchOnlyAccountsScreen.kt | 216 ++++++++++++++++ .../advanced/WatchOnlyAccountsViewModel.kt | 86 +++++++ .../to/bitkit/usecases/WipeWalletUseCase.kt | 3 + app/src/main/res/values/strings.xml | 18 ++ .../to/bitkit/models/PubkyAuthRequestTest.kt | 72 ++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 6 + .../WatchOnlyAccountClaimCodecTest.kt | 85 +++++++ .../bitkit/services/LightningServiceTest.kt | 3 + .../profile/PubkyAuthApprovalViewModelTest.kt | 105 +++++++- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 5 + .../next/watch-only-account-claim.added.md | 1 + docs/watch-only-account-claim-v1.md | 48 ++++ gradle/libs.versions.toml | 2 +- 28 files changed, 1306 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt create mode 100644 app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt create mode 100644 app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt create mode 100644 app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt create mode 100644 app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt create mode 100644 changelog.d/next/watch-only-account-claim.added.md create mode 100644 docs/watch-only-account-claim-v1.md 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..ec0f1b26b4 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -0,0 +1,53 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +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.WatchOnlyAccountRecord +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.watchOnlyAccountDataStore: DataStore by dataStore( + fileName = "watch_only_accounts.json", + serializer = WatchOnlyAccountDataSerializer, +) + +@Singleton +class WatchOnlyAccountStore @Inject constructor( + @ApplicationContext context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + private val store = context.watchOnlyAccountDataStore + + val data: Flow = store.data + + suspend fun load(): List = withContext(ioDispatcher) { + store.data.first().accounts + } + + suspend fun save(accounts: List) = withContext(ioDispatcher) { + store.updateData { WatchOnlyAccountData(accounts.sortedBy(WatchOnlyAccountRecord::accountIndex)) } + 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(), +) 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..6b68da6ddb --- /dev/null +++ b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt @@ -0,0 +1,26 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.Serializer +import kotlinx.serialization.SerializationException +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.di.json +import to.bitkit.utils.Logger +import java.io.InputStream +import java.io.OutputStream + +object WatchOnlyAccountDataSerializer : Serializer { + private const val TAG = "WatchOnlyAccountDataSerializer" + + override val defaultValue = WatchOnlyAccountData() + + override suspend fun readFrom(input: InputStream): WatchOnlyAccountData = try { + json.decodeFromString(input.readBytes().decodeToString()) + } catch (error: SerializationException) { + Logger.error("Deserialize watch-only account data failed", error, context = TAG) + defaultValue + } + + 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..d54f99cc50 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -21,6 +21,7 @@ data class WalletBackupV1( val transfers: List, val privatePaykitHighestReservedReceiveIndexByAddressType: Map? = null, val paykitSdkBackupState: String? = null, + val watchOnlyAccounts: List? = 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..3e15b9b5dc 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -1,6 +1,32 @@ 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(message: String, cause: Throwable? = null) : AppError(message, cause) { + class InvalidUrl(cause: Throwable) : PubkyAuthRequestError("Invalid Pubky auth URL", cause) + data object DuplicateBitkitClaim : PubkyAuthRequestError("Duplicate Bitkit claim") + data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError("Unsupported Bitkit claim '$value'") + data object InvalidBitkitClaimCapabilities : PubkyAuthRequestError("Invalid capabilities for Bitkit claim") +} @Immutable data class PubkyAuthPermission( @@ -20,10 +46,69 @@ 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() -> 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 +125,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..9fc892f7a1 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt @@ -0,0 +1,40 @@ +package to.bitkit.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.lightningdevkit.ldknode.Network +import to.bitkit.env.Env + +@Serializable +enum class WatchOnlyAccountSetupState { + @SerialName("pendingDelivery") + PendingDelivery, + + @SerialName("active") + Active, +} + +@Serializable +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..5cf69a1f61 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,7 @@ class BackupRepo @Inject constructor( private val vssBackupClientLdk: VssBackupClientLdk, private val settingsStore: SettingsStore, private val widgetsStore: WidgetsStore, + private val watchOnlyAccountStore: WatchOnlyAccountStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val pubkyRepo: PubkyRepo, @@ -263,6 +265,18 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(transfersJob) + val watchOnlyAccountsJob = scope.launch { + watchOnlyAccountStore.data + .map { it.accounts } + .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 @@ -550,6 +564,7 @@ class BackupRepo @Inject constructor( transfers = transfers, privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, paykitSdkBackupState = paykitSdkBackupState, + watchOnlyAccounts = watchOnlyAccountStore.load(), ) return json.encodeToString(payload).toByteArray() @@ -631,6 +646,7 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) + watchOnlyAccountStore.save(parsed.watchOnlyAccounts.orEmpty()) if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) { cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index cd416318a8..ee707da467 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -5,7 +5,6 @@ import android.graphics.BitmapFactory import coil3.ImageLoader import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile -import com.synonym.paykit.PubkyAuthDetails import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -41,6 +40,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink @@ -898,9 +898,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() } } 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..42f1abf154 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -0,0 +1,238 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.lightningdevkit.ldknode.AddressType +import to.bitkit.async.ServiceQueue +import to.bitkit.data.WatchOnlyAccountStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.fromHex +import to.bitkit.models.PreparedWatchOnlyAccountClaim +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.services.LightningService +import to.bitkit.utils.AppError +import java.math.BigInteger +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 + +sealed class WatchOnlyAccountError(message: String) : AppError(message) { + data object InvalidAccountName : WatchOnlyAccountError("Enter an account name between 1 and 64 characters.") + data object InvalidAuthRequest : WatchOnlyAccountError("This authorization request is missing a valid secret.") + data object InvalidExtendedPublicKey : WatchOnlyAccountError("Bitkit could not create a valid account xpub.") + data object NodeUnavailable : WatchOnlyAccountError("The wallet must be running before an account can be created.") + data object CompanionTransportUnavailable : WatchOnlyAccountError( + "This request needs the signed watch-only claim transport from the Paykit SDK before it can be authorized." + ) +} + +@Singleton +class WatchOnlyAccountClaimTransport @Inject constructor() { + suspend fun deliver( + @Suppress("UNUSED_PARAMETER") payload: ByteArray, + @Suppress("UNUSED_PARAMETER") authUrl: String, + ) { + throw WatchOnlyAccountError.CompanionTransportUnavailable + } +} + +@Singleton +class WatchOnlyAccountRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val store: WatchOnlyAccountStore, + private val lightningService: LightningService, + private val keychain: Keychain, + private val transport: WatchOnlyAccountClaimTransport, +) { + private val mutationMutex = Mutex() + + val accounts: Flow> = store.data.map { it.accounts } + + suspend fun prepareSignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim = + withContext(bgDispatcher) { + mutationMutex.withLock { + val normalizedName = normalizeName(name) + val walletIndex = lightningService.currentWalletIndex + val fingerprint = sha256(authUrl.encodeToByteArray()).toBase64() + val current = store.load() + current.firstOrNull { + it.walletIndex == walletIndex && it.requestFingerprint == fingerprint + }?.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, authUrl, localSecretKey()), + ) + } + + val highestAccountIndex = current + .filter { it.walletIndex == walletIndex } + .maxOfOrNull(WatchOnlyAccountRecord::accountIndex) ?: 0 + check(highestAccountIndex < Int.MAX_VALUE) { "Watch-only account index overflow" } + val accountIndex = highestAccountIndex + 1 + val xpub = createAndTrackAccount(accountIndex) + val account = WatchOnlyAccountRecord( + id = UUID.randomUUID().toString(), + walletIndex = walletIndex, + accountIndex = accountIndex, + addressType = ADDRESS_TYPE_NATIVE_SEGWIT, + xpub = xpub, + requestFingerprint = fingerprint, + createdAt = System.currentTimeMillis(), + name = normalizedName, + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + store.save(current + account) + PreparedWatchOnlyAccountClaim( + account = account, + payload = WatchOnlyAccountClaimCodec.encode(account, authUrl, localSecretKey()), + ) + } + } + + suspend fun deliver(prepared: PreparedWatchOnlyAccountClaim, authUrl: String) { + transport.deliver(prepared.payload, authUrl) + } + + suspend fun markActive(id: String) = updateAccount(id) { it.copy(setupState = WatchOnlyAccountSetupState.Active) } + + suspend fun rename(id: String, name: String) { + val normalizedName = normalizeName(name) + updateAccount(id) { it.copy(name = normalizedName) } + } + + suspend fun setTrackingEnabled(id: String, enabled: Boolean) = + updateAccount(id) { it.copy(isTrackingEnabled = enabled) } + + suspend fun restore(accounts: List?) { + store.save(accounts.orEmpty()) + } + + private suspend fun createAndTrackAccount(accountIndex: Int): String = ServiceQueue.LDK.background { + val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable + val index = accountIndex.toUInt() + val xpub = node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, index) + if (node.listOnchainWalletAccounts().none { + it.addressType == AddressType.NATIVE_SEGWIT && it.accountIndex == index + } + ) { + node.addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, index, xpub) + } + node.syncWallets() + xpub + } + + private suspend fun updateAccount(id: String, transform: (WatchOnlyAccountRecord) -> WatchOnlyAccountRecord) { + mutationMutex.withLock { + store.update { accounts -> accounts.map { if (it.id == id) transform(it) else it } } + } + } + + private fun localSecretKey(): ByteArray = requireNotNull( + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)?.takeIf(String::isNotBlank) + ) { "No local Pubky identity is available" }.fromHex() + + private fun normalizeName(name: String): String { + val normalized = name.trim() + if (normalized.isEmpty() || normalized.length > MAX_NAME_LENGTH) { + throw WatchOnlyAccountError.InvalidAccountName + } + return normalized + } + + companion object { + const val ADDRESS_TYPE_NATIVE_SEGWIT = "nativeSegwit" + private const val MAX_NAME_LENGTH = 64 + } +} + +object WatchOnlyAccountClaimCodec { + const val VERSION: Byte = 1 + const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 + const val SERIALIZED_XPUB_LENGTH = 78 + const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + 64 + + private val signatureDomain = "x-bitkit-claim|watch-only-account-v1|".encodeToByteArray() + private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private const val BASE58_RADIX = 58L + private const val BASE58_CHECKSUM_LENGTH = 4 + + fun encode(account: WatchOnlyAccountRecord, authUrl: String, secretKey: ByteArray): ByteArray { + if (account.addressType != WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT || secretKey.size != 32) { + throw WatchOnlyAccountError.InvalidExtendedPublicKey + } + val rawXpub = decodeBase58Check(account.xpub) + if (rawXpub.size != SERIALIZED_XPUB_LENGTH) throw WatchOnlyAccountError.InvalidExtendedPublicKey + + val unsignedClaim = ByteBuffer.allocate(1 + 4 + 1 + SERIALIZED_XPUB_LENGTH) + .put(VERSION) + .putInt(account.accountIndex) + .put(NATIVE_SEGWIT_ADDRESS_TYPE) + .put(rawXpub) + .array() + val signable = signatureDomain + requestSecretHash(authUrl) + unsignedClaim + val signer = Ed25519Signer().apply { + init(true, Ed25519PrivateKeyParameters(secretKey, 0)) + update(signable, 0, signable.size) + } + return unsignedClaim + signer.generateSignature() + } + + fun requestSecretHash(authUrl: String): ByteArray { + val rawQuery = runCatching { URI(authUrl).rawQuery }.getOrNull() + ?: throw WatchOnlyAccountError.InvalidAuthRequest + val secrets = rawQuery.split('&').mapNotNull { item -> + val parts = item.split('=', limit = 2) + if (percentDecode(parts[0]) != "secret") return@mapNotNull null + percentDecode(parts.getOrElse(1) { "" }) + } + if (secrets.size != 1 || secrets.first().isEmpty()) throw WatchOnlyAccountError.InvalidAuthRequest + return sha256(secrets.first().encodeToByteArray()) + } + + private fun decodeBase58Check(value: String): ByteArray { + if (value.isEmpty()) invalidExtendedPublicKey() + var number = BigInteger.ZERO + value.forEach { character -> + val digit = BASE58_ALPHABET.indexOf(character) + if (digit < 0) invalidExtendedPublicKey() + number = number.multiply(BigInteger.valueOf(BASE58_RADIX)).add(BigInteger.valueOf(digit.toLong())) + } + val magnitude = number.toByteArray().let { + if (it.size > 1 && it[0] == 0.toByte()) it.drop(1).toByteArray() else it + } + val decoded = ByteArray(value.takeWhile { it == '1' }.length) + magnitude + if (decoded.size <= BASE58_CHECKSUM_LENGTH) invalidExtendedPublicKey() + val payload = decoded.copyOfRange(0, decoded.size - BASE58_CHECKSUM_LENGTH) + val checksum = decoded.copyOfRange(decoded.size - BASE58_CHECKSUM_LENGTH, decoded.size) + val expected = sha256(sha256(payload)).copyOf(BASE58_CHECKSUM_LENGTH) + if (!MessageDigest.isEqual(checksum, expected)) invalidExtendedPublicKey() + return payload + } + + private fun invalidExtendedPublicKey(): Nothing = throw WatchOnlyAccountError.InvalidExtendedPublicKey + + private fun percentDecode(value: String): String = + URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8) +} + +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..210c941469 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -45,6 +45,7 @@ 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 @@ -84,6 +85,7 @@ class LightningService @Inject constructor( private val keychain: Keychain, private val vssStoreIdProvider: VssStoreIdProvider, private val settingsStore: SettingsStore, + private val watchOnlyAccountStore: WatchOnlyAccountStore, private val loggerLdk: LoggerLdk, ) : BaseCoroutineScope(bgDispatcher) { @@ -114,6 +116,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,17 +137,43 @@ 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, ) + registerEnabledWatchOnlyAccounts(builtNode, walletIndex) + currentWalletIndex = walletIndex + node = builtNode Logger.info("LDK node setup", context = TAG) } + private suspend fun registerEnabledWatchOnlyAccounts(node: Node, walletIndex: Int) { + val records = watchOnlyAccountStore.load().filter { + it.walletIndex == walletIndex && it.isTrackingEnabled + } + if (records.isEmpty()) return + + ServiceQueue.LDK.background { + val registered = node.listOnchainWalletAccounts() + .map { it.addressType to it.accountIndex } + .toMutableSet() + records.forEach { record -> + val addressType = when (record.addressType) { + "nativeSegwit" -> LdkAddressType.NATIVE_SEGWIT + else -> throw IllegalArgumentException("Unsupported watch-only account address type") + } + val accountIndex = record.accountIndex.toUInt() + if (registered.add(addressType to accountIndex)) { + node.addOnchainWalletAccount(addressType, accountIndex, record.xpub) + } + } + } + } + private fun config( walletIndex: Int, trustedPeers: List?, 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/TextInput.kt b/app/src/main/java/to/bitkit/ui/components/TextInput.kt index 7f4d524fbf..23acfc66d9 100644 --- a/app/src/main/java/to/bitkit/ui/components/TextInput.kt +++ b/app/src/main/java/to/bitkit/ui/components/TextInput.kt @@ -34,6 +34,7 @@ fun TextInput( placeholder: String? = null, value: String, onValueChange: (String) -> Unit, + enabled: Boolean = true, singleLine: Boolean = false, isError: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, @@ -69,6 +70,7 @@ fun TextInput( } TextField( + enabled = enabled, placeholder = { placeholder?.let { Text(placeholder, color = Colors.White64, style = textStyle) 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..2e8bf0aad4 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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -30,6 +31,7 @@ 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 @@ -45,6 +47,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel @@ -111,6 +114,7 @@ fun PubkyAuthApprovalSheet( Content( uiState = uiState, onAuthorize = { viewModel.requestAuthorize(authUrl) }, + onAccountNameChange = viewModel::updateWatchOnlyAccountName, onCancel = { viewModel.dismiss() }, onDismiss = { viewModel.dismiss() }, ) @@ -167,6 +171,7 @@ internal fun resolvePubkyApprovalLocalAuthMode( private fun Content( uiState: PubkyAuthApprovalUiState, onAuthorize: () -> Unit, + onAccountNameChange: (String) -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, ) { @@ -190,6 +195,7 @@ private fun Content( ApprovalState.Authorize -> AuthorizeContent( uiState = uiState, onAuthorize = onAuthorize, + onAccountNameChange = onAccountNameChange, onCancel = onCancel, ) ApprovalState.Authorizing -> AuthorizingContent( @@ -219,6 +225,7 @@ private fun ColumnScope.LoadingContent() { private fun ColumnScope.AuthorizeContent( uiState: PubkyAuthApprovalUiState, onAuthorize: () -> Unit, + onAccountNameChange: (String) -> Unit, onCancel: () -> Unit, ) { DescriptionText(serviceName = uiState.serviceName) @@ -227,6 +234,17 @@ private fun ColumnScope.AuthorizeContent( PermissionsSection(permissions = uiState.permissions) VerticalSpacer(16.dp) + uiState.bitkitClaim?.let { + BitkitClaimSection(it) + VerticalSpacer(16.dp) + WatchOnlyAccountName( + value = uiState.watchOnlyAccountName, + enabled = true, + onValueChange = onAccountNameChange, + ) + VerticalSpacer(16.dp) + } + FillHeight() TrustWarning() @@ -260,6 +278,17 @@ private fun ColumnScope.AuthorizingContent( PermissionsSection(permissions = uiState.permissions) VerticalSpacer(16.dp) + uiState.bitkitClaim?.let { + BitkitClaimSection(it) + VerticalSpacer(16.dp) + WatchOnlyAccountName( + value = uiState.watchOnlyAccountName, + enabled = false, + onValueChange = {}, + ) + VerticalSpacer(16.dp) + } + FillHeight() TrustWarning() @@ -366,6 +395,51 @@ private fun PermissionRow(permission: PubkyAuthPermission) { } } +@Composable +private fun BitkitClaimSection(bitkitClaim: PubkyAuthClaim) { + when (bitkitClaim) { + PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> Column( + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + Text13Up( + text = stringResource(R.string.profile__auth_approval_watch_only_account_title), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource(R.string.profile__auth_approval_watch_only_account_description), + color = Colors.White64, + ) + } + } +} + +@Composable +private fun WatchOnlyAccountName( + value: String, + enabled: Boolean, + onValueChange: (String) -> Unit, +) { + Text13Up( + text = stringResource(R.string.profile__auth_approval_watch_only_account_name), + color = Colors.White64, + ) + VerticalSpacer(8.dp) + TextInput( + value = value, + onValueChange = onValueChange, + enabled = enabled, + placeholder = stringResource(R.string.profile__auth_approval_watch_only_account_name_placeholder), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag("PubkyAuthWatchOnlyAccountName"), + ) +} + @Composable private fun TrustWarning() { BodyM( @@ -405,6 +479,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", @@ -415,6 +490,7 @@ private fun AuthorizePreview() { ), ), onAuthorize = {}, + onAccountNameChange = {}, onCancel = {}, onDismiss = {}, ) @@ -433,6 +509,7 @@ private fun SuccessPreview() { serviceName = "pubky.app", ), onAuthorize = {}, + onAccountNameChange = {}, 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..fb78176b76 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 @@ -16,11 +16,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +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.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -29,6 +30,7 @@ import javax.inject.Inject 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" @@ -42,7 +44,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( fun load(authUrl: String) { viewModelScope.launch { - val details = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { Logger.error("Failed to parse auth request", it, context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -52,19 +54,23 @@ class PubkyAuthApprovalViewModel @Inject constructor( _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() val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) - val serviceName = serviceNames.firstOrNull() ?: unknownService + val serviceName = request.serviceNames.firstOrNull() ?: unknownService val profile = pubkyRepo.profile.value + val accountName = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) + } else { + "" + } _uiState.update { it.copy( state = ApprovalState.Authorize, serviceName = serviceName, - requestedCapabilities = caps, - permissions = permissions.toImmutableList(), + requestedCapabilities = request.capabilities, + permissions = request.permissions.toImmutableList(), + bitkitClaim = request.bitkitClaim, + watchOnlyAccountName = accountName, profile = profile, ) } @@ -77,6 +83,10 @@ class PubkyAuthApprovalViewModel @Inject constructor( } } + fun updateWatchOnlyAccountName(name: String) { + _uiState.update { it.copy(watchOnlyAccountName = name) } + } + fun confirmAuthorize(authUrl: String) { viewModelScope.launch { _uiState.update { it.copy(state = ApprovalState.Authorizing) } @@ -90,26 +100,47 @@ class PubkyAuthApprovalViewModel @Inject constructor( description = it.message, ) return@launch - }.capabilities.orEmpty() + }.capabilities } - pubkyRepo.approveAuth(authUrl, capabilities) - .onSuccess { - Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG) - _uiState.update { it.copy(state = ApprovalState.Success) } - } - .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 preparedClaim = runCatching { + if (_uiState.value.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + watchOnlyAccountRepo.prepareSignedClaim(authUrl, _uiState.value.watchOnlyAccountName).also { + watchOnlyAccountRepo.deliver(it, authUrl) + } + } else { + null } + }.getOrElse { + handleApprovalFailure(it) + return@launch + } + + val approvalResult = pubkyRepo.approveAuth(authUrl, capabilities) + if (approvalResult.isFailure) { + handleApprovalFailure(approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed")) + return@launch + } + + preparedClaim?.let { claim -> + runCatching { watchOnlyAccountRepo.markActive(claim.account.id) } + .onFailure { Logger.error("Failed to mark watch-only account active", it, context = TAG) } + } + Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG) + _uiState.update { it.copy(state = ApprovalState.Success) } } } + private suspend fun handleApprovalFailure(error: Throwable) { + Logger.error("Auth approval failed", error, 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 = error.message, + ) + } + fun dismiss() { viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.Dismiss) } } @@ -121,6 +152,8 @@ data class PubkyAuthApprovalUiState( val serviceName: String = "", val requestedCapabilities: String = "", val permissions: ImmutableList = persistentListOf(), + val bitkitClaim: PubkyAuthClaim? = null, + val watchOnlyAccountName: String = "", val profile: PubkyProfile? = null, ) 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..b1b0bd87e8 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,8 @@ 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 to.bitkit.services.LightningService import javax.inject.Inject private const val NODE_ID_PREFIX_LENGTH = 5 @@ -23,8 +25,12 @@ private const val ELECTRUM_HOST_PREFIX_LENGTH = 5 class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, + watchOnlyAccountRepo: WatchOnlyAccountRepo, + lightningService: LightningService, ) : ViewModel() { + private val walletIndex = lightningService.currentWalletIndex + val selectedAddressTypeName = settingsStore.data .map { it.selectedAddressType.toAddressType()?.addressTypeInfo()?.shortName ?: "" } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") @@ -33,6 +39,10 @@ class AdvancedSettingsViewModel @Inject constructor( .map { it.channels.filterOpen().size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + val watchOnlyAccountCount = watchOnlyAccountRepo.accounts + .map { accounts -> accounts.count { it.walletIndex == walletIndex } } + .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..6f2977d85f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt @@ -0,0 +1,216 @@ +package to.bitkit.ui.settings.advanced + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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 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.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.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +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 updatingAccountId by viewModel.updatingAccountId.collectAsStateWithLifecycle() + + Content( + accounts = accounts, + updatingAccountId = updatingAccountId, + onBack = { navController.popBackStack() }, + onRename = viewModel::rename, + onTrackingChange = viewModel::setTrackingEnabled, + ) +} + +@Composable +private fun Content( + accounts: List, + updatingAccountId: String?, + onBack: () -> Unit, + onRename: (WatchOnlyAccountRecord, String) -> Unit, + onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, +) { + ScreenColumn(modifier = Modifier.testTag("WatchOnlyAccountsScreen")) { + AppTopBar( + titleText = stringResource(R.string.watch_only_accounts__title), + onBackClick = onBack, + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + item { + BodyM( + text = stringResource(R.string.watch_only_accounts__description), + color = Colors.White64, + ) + } + if (accounts.isEmpty()) { + item { EmptyState() } + } else { + items(accounts, key = WatchOnlyAccountRecord::id) { account -> + AccountCard( + account = account, + isUpdating = updatingAccountId != null, + onRename = onRename, + onTrackingChange = onTrackingChange, + ) + } + } + item { VerticalSpacer(32.dp) } + } + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(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, + ) + } +} + +@Composable +private fun AccountCard( + account: WatchOnlyAccountRecord, + isUpdating: Boolean, + onRename: (WatchOnlyAccountRecord, String) -> Unit, + onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, +) { + var name by remember(account.id) { mutableStateOf(account.name) } + LaunchedEffect(account.name) { name = 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), + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(20.dp) + .testTag("WatchOnlyAccount_${account.accountIndex}"), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + BodySSB(account.name) + BodyM(account.derivationPath, color = Colors.White64) + } + Switch( + checked = account.isTrackingEnabled, + onCheckedChange = { onTrackingChange(account, it) }, + enabled = !isUpdating, + modifier = Modifier.testTag("WatchOnlyAccountTracking_${account.accountIndex}"), + ) + } + + Column { + 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}"), + ) + } + + Column { + Caption13Up(stringResource(R.string.watch_only_accounts__xpub), color = Colors.White64) + VerticalSpacer(8.dp) + BodyM( + text = account.xpub, + color = Colors.White64, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("WatchOnlyAccountXpub_${account.accountIndex}"), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SecondaryButton( + text = stringResource(R.string.watch_only_accounts__save_name), + onClick = { onRename(account, 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}"), + ) + } + + if (account.setupState == WatchOnlyAccountSetupState.PendingDelivery) { + BodyM( + text = stringResource(R.string.watch_only_accounts__pending_delivery), + color = Colors.Yellow, + modifier = Modifier.testTag("WatchOnlyAccountPending_${account.accountIndex}"), + ) + } + } +} 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..abe0fcde28 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt @@ -0,0 +1,86 @@ +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.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.launch +import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WatchOnlyAccountRepo +import to.bitkit.services.LightningService +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +@HiltViewModel +class WatchOnlyAccountsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, + private val lightningRepo: LightningRepo, + lightningService: LightningService, +) : ViewModel() { + private val walletIndex = lightningService.currentWalletIndex + private val _updatingAccountId = MutableStateFlow(null) + val updatingAccountId = _updatingAccountId.asStateFlow() + + val accounts = watchOnlyAccountRepo.accounts + .map { accounts -> accounts.filter { it.walletIndex == walletIndex } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun rename(account: WatchOnlyAccountRecord, name: String) { + viewModelScope.launch { + runCatching { 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 { + _updatingAccountId.value = account.id + runCatching { + watchOnlyAccountRepo.setTrackingEnabled(account.id, enabled) + lightningRepo.restartNode().getOrThrow() + }.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 -> + runCatching { + watchOnlyAccountRepo.setTrackingEnabled(account.id, account.isTrackingEnabled) + lightningRepo.restartNode().getOrThrow() + } + showError(error) + } + _updatingAccountId.value = null + } + } + + private suspend fun showError(error: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.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..dd5bab9aa4 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -4,6 +4,7 @@ import com.google.firebase.messaging.FirebaseMessaging 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.keychain.Keychain import to.bitkit.ext.runSuspendCatching @@ -31,6 +32,7 @@ class WipeWalletUseCase @Inject constructor( private val db: AppDb, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, + private val watchOnlyAccountStore: WatchOnlyAccountStore, private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, @@ -66,6 +68,7 @@ class WipeWalletUseCase @Inject constructor( settingsStore.reset() cacheStore.reset() + watchOnlyAccountStore.save(emptyList()) widgetsStore.reset() blocktankRepo.resetState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83f888e..a794402ba1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,6 +574,24 @@ 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. + Share a watch-only Bitcoin account. This lets the service generate receive addresses and view this account’s transactions and balance, but not spend funds. + ACCOUNT NAME + Name this account + %1$s server + Watch-only Bitcoin account + Server accounts + Each approved service gets a separate Bitcoin account. Turning tracking off rebuilds the wallet without that account; it does not revoke the service session. + No server accounts + Accounts you approve for Paykit servers will appear here. + NAME + Account name + EXTENDED PUBLIC KEY + Save name + Copy xpub + Claim delivery is pending. + Account name saved + Account tracking enabled + Account tracking disabled Authorization Failed Failed to read selected image Create profile with Bitkit diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 6431bd2bab..d337e10278 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -2,11 +2,76 @@ 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 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") @@ -81,4 +146,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..0f57ef2df6 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -27,6 +27,8 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.dao.TransferDao @@ -53,6 +55,7 @@ class BackupRepoTest : BaseUnitTest() { private val vssBackupClientLdk = mock() private val settingsStore = mock() private val widgetsStore = mock() + private val watchOnlyAccountStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() private val pubkyRepo = mock() @@ -81,6 +84,8 @@ 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 { vssBackupClient.getObject(any()) }.thenReturn(Result.success(null)) whenever { vssBackupClient.putObject(any(), any()) } .thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1))) @@ -324,6 +329,7 @@ class BackupRepoTest : BaseUnitTest() { vssBackupClientLdk = vssBackupClientLdk, settingsStore = settingsStore, widgetsStore = widgetsStore, + watchOnlyAccountStore = watchOnlyAccountStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, pubkyRepo = pubkyRepo, 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..3eb11218c7 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -0,0 +1,85 @@ +package to.bitkit.repositories + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Test +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchOnlyAccountClaimCodecTest { + @Test + fun `signed claim contains account metadata and request-bound signature`() { + val rawXpub = ByteArray(WatchOnlyAccountClaimCodec.SERIALIZED_XPUB_LENGTH) { it.toByte() } + val privateKeyBytes = ByteArray(32) { 7 } + val authUrl = "pubkyauth://signin?secret=request-secret" + val account = account(accountIndex = 42, xpub = base58CheckEncode(rawXpub)) + + val payload = WatchOnlyAccountClaimCodec.encode(account, authUrl, privateKeyBytes) + + 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)) + + val unsignedClaim = payload.copyOfRange(0, 84) + val signature = payload.copyOfRange(84, payload.size) + val signable = "x-bitkit-claim|watch-only-account-v1|".encodeToByteArray() + + WatchOnlyAccountClaimCodec.requestSecretHash(authUrl) + + unsignedClaim + val verifier = Ed25519Signer().apply { + init(false, Ed25519PrivateKeyParameters(privateKeyBytes, 0).generatePublicKey()) + update(signable, 0, signable.size) + } + assertTrue(verifier.verifySignature(signature)) + } + + @Test + fun `request secret hashing percent decodes without treating plus as a space`() { + val encoded = WatchOnlyAccountClaimCodec.requestSecretHash( + "pubkyauth://signin?secret=one%2Ftwo+three", + ) + + assertContentEquals(sha256("one/two+three".encodeToByteArray()), encoded) + } + + private fun account(accountIndex: Int, xpub: String) = WatchOnlyAccountRecord( + id = "id", + walletIndex = 0, + accountIndex = accountIndex, + addressType = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + xpub = xpub, + requestFingerprint = "request", + createdAt = 1, + name = "Test", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + private fun base58CheckEncode(payload: ByteArray): String { + val checksum = sha256(sha256(payload)).copyOf(4) + val source = payload + checksum + var number = BigInteger(1, source) + val output = StringBuilder() + val base = BigInteger.valueOf(58) + while (number > BigInteger.ZERO) { + val division = number.divideAndRemainder(base) + output.append(BASE58_ALPHABET[division[1].toInt()]) + number = division[0] + } + repeat(source.takeWhile { it == 0.toByte() }.size) { output.append('1') } + return output.reverse().toString() + } + + private fun sha256(value: ByteArray) = MessageDigest.getInstance("SHA-256").digest(value) + + companion object { + private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + } +} diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index e77bf24ae2..1ea0b6530f 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -6,6 +6,7 @@ import org.lightningdevkit.ldknode.Node import org.mockito.kotlin.mock import org.mockito.kotlin.whenever 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.ext.createChannelDetails @@ -18,6 +19,7 @@ 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() @@ -30,6 +32,7 @@ class LightningServiceTest : BaseUnitTest() { keychain = keychain, vssStoreIdProvider = vssStoreIdProvider, settingsStore = settingsStore, + watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, ) sut.node = node 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..606259d764 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,15 +1,22 @@ package to.bitkit.ui.screens.profile import android.content.Context -import com.synonym.paykit.PubkyAuthDetails -import com.synonym.paykit.PubkyAuthRequestKind import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.mock 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.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -17,6 +24,7 @@ import kotlin.test.assertEquals class PubkyAuthApprovalViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() + private val watchOnlyAccountRepo: WatchOnlyAccountRepo = mock() @Test fun `initial state is loading`() { @@ -29,29 +37,110 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { fun `confirmAuthorize reparses capabilities when load has not completed`() = test { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" val capabilities = "/pub/bitkit.to/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + ) + whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Success, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + } + + @Test + fun `load exposes watch-only account claim for approval`() = test { + val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) 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, ), ), ) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) + assertEquals("paykit server", sut.uiState.value.watchOnlyAccountName) + } + + @Test + fun `watch-only authorization delivers signed claim before approving session`() = 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(148), + ) + whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareSignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { watchOnlyAccountRepo.deliver(prepared, authUrl) }.thenReturn(Unit) whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() + sut.load(authUrl) + advanceUntilIdle() sut.confirmAuthorize(authUrl) advanceUntilIdle() assertEquals(ApprovalState.Success, sut.uiState.value.state) - verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) } + verifyBlocking(watchOnlyAccountRepo) { prepareSignedClaim(authUrl, "paykit server") } + verifyBlocking(watchOnlyAccountRepo) { deliver(prepared, authUrl) } verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + verifyBlocking(watchOnlyAccountRepo) { 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 = true, + 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..96f65b0d07 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.whenever 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.keychain.Keychain import to.bitkit.repositories.ActivityRepo @@ -38,6 +39,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val db = mock() private val settingsStore = mock() private val cacheStore = mock() + private val watchOnlyAccountStore = 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, + watchOnlyAccountStore = watchOnlyAccountStore, widgetsStore = widgetsStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, @@ -100,6 +103,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db, settingsStore, cacheStore, + watchOnlyAccountStore, widgetsStore, blocktankRepo, activityRepo, @@ -121,6 +125,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() + inOrder.verify(watchOnlyAccountStore).save(emptyList()) inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/watch-only-account-claim.added.md new file mode 100644 index 0000000000..960bfa65c0 --- /dev/null +++ b/changelog.d/next/watch-only-account-claim.added.md @@ -0,0 +1 @@ +Bitkit now creates, names, signs, backs up, and manages separate watch-only Bitcoin accounts for approved Paykit server setup requests. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md new file mode 100644 index 0000000000..7eb4f0f2f4 --- /dev/null +++ b/docs/watch-only-account-claim-v1.md @@ -0,0 +1,48 @@ +# 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`. +- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Retrying the same auth URL reuses its pending account. +- The user assigns a local name to the account. The name is not disclosed in the claim. + +## Signed claim + +The decrypted claim is 148 bytes: + +| 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 | +| 84 | 64 | Ed25519 signature | + +The signature input is the byte concatenation: + +```text +UTF8("x-bitkit-claim|watch-only-account-v1|") +|| SHA256(UTF8(decoded_auth_request_secret)) +|| claim_bytes[0..<84] +``` + +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))}`. +- Bitkit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using the existing XSalsa20-Poly1305 format. +- Bitkit 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 retries the same pending claim idempotently. +- Disabling tracking rebuilds LDK Node without registering that account. It does not delete the xpub or revoke the server session. +- Account metadata is included in the existing encrypted wallet backup and uses the same JSON field names on iOS and Android. + +## Required shared protocol work + +The current Paykit app bindings do not expose the companion encrypted-relay operation, so both clients deliberately fail closed at delivery. Paykit must provide one shared binding that derives the domain-separated channel, encrypts, and posts the signed claim; duplicating Pubky relay cryptography in each app is not accepted. + +The server protocol must also communicate its highest issued external address index. Bitkit can then call LDK Node's account-specific reveal API before syncing. Without that high-water mark, addresses beyond the wallet lookahead cannot be guaranteed to appear, even though initial addresses work normally. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 278d17fee6..4425d19900 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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.54" } 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" } From cab307eb19780c9476989c8e2e8bac30442c4107 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 14:15:32 +0200 Subject: [PATCH 02/13] fix: harden paykit account lifecycle --- app/build.gradle.kts | 3 + .../to/bitkit/data/WatchOnlyAccountStore.kt | 139 ++++++++- .../WatchOnlyAccountDataSerializer.kt | 7 +- .../java/to/bitkit/models/BackupPayloads.kt | 2 + .../java/to/bitkit/models/PubkyAuthRequest.kt | 13 +- .../java/to/bitkit/models/WatchOnlyAccount.kt | 7 + .../java/to/bitkit/repositories/BackupRepo.kt | 12 +- .../to/bitkit/repositories/LightningRepo.kt | 5 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 20 ++ .../repositories/WatchOnlyAccountRepo.kt | 292 +++++++++++------- .../to/bitkit/services/LightningService.kt | 116 +++++-- .../to/bitkit/services/PaykitSdkService.kt | 16 + .../java/to/bitkit/services/PubkyService.kt | 23 +- .../screens/profile/PubkyAuthApprovalSheet.kt | 109 ++++--- .../profile/PubkyAuthApprovalViewModel.kt | 102 ++++-- .../advanced/WatchOnlyAccountsScreen.kt | 230 +++++++++----- .../advanced/WatchOnlyAccountsViewModel.kt | 55 ++-- .../bitkit/ui/utils/PubkyAuthErrorMessage.kt | 28 ++ .../to/bitkit/usecases/WipeWalletUseCase.kt | 2 +- app/src/main/res/values/strings.xml | 42 ++- .../bitkit/data/WatchOnlyAccountStoreTest.kt | 127 ++++++++ .../WatchOnlyAccountDataSerializerTest.kt | 17 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 12 + .../to/bitkit/repositories/BackupRepoTest.kt | 81 ++++- .../bitkit/repositories/LightningRepoTest.kt | 19 ++ .../to/bitkit/repositories/PubkyRepoTest.kt | 23 ++ .../WatchOnlyAccountClaimCodecTest.kt | 52 +--- .../repositories/WatchOnlyAccountRepoTest.kt | 240 ++++++++++++++ .../bitkit/services/LightningServiceTest.kt | 88 ++++++ .../profile/PubkyAuthApprovalViewModelTest.kt | 254 ++++++++++++++- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 2 +- .../next/watch-only-account-claim.added.md | 2 +- docs/watch-only-account-claim-v1.md | 29 +- gradle/libs.versions.toml | 5 +- 34 files changed, 1746 insertions(+), 428 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt create mode 100644 app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt create mode 100644 app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 88f5f616f0..eb4b845f9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -382,6 +382,9 @@ dependencies { implementation(libs.camera.view) // Crypto implementation(libs.bouncycastle.provider.jdk) + implementation(libs.bitcoinj.core) { + exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") + } implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") } implementation(libs.bitkit.core) implementation(libs.paykit) diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt index ec0f1b26b4..faf752cec9 100644 --- a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.Serializable import to.bitkit.data.serializers.WatchOnlyAccountDataSerializer import to.bitkit.di.IoDispatcher import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import javax.inject.Inject import javax.inject.Singleton @@ -33,8 +34,53 @@ class WatchOnlyAccountStore @Inject constructor( store.data.first().accounts } + 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 { WatchOnlyAccountData(accounts.sortedBy(WatchOnlyAccountRecord::accountIndex)) } + 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) } + 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 } @@ -50,4 +96,95 @@ class WatchOnlyAccountStore @Inject constructor( @Serializable data class WatchOnlyAccountData( val accounts: List = emptyList(), + val highestAccountIndexByWallet: Map = emptyMap(), + val pendingAccountIndexByRequest: Map = emptyMap(), ) + +@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.completeAccountIndexAllocation(requestKey: String): WatchOnlyAccountData = + copy(pendingAccountIndexByRequest = pendingAccountIndexByRequest - requestKey) + +internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccountData { + val account = accounts.firstOrNull { it.id == id } ?: return this + val requestKey = "${account.walletIndex}:${account.requestFingerprint}" + 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, +): WatchOnlyAccountData { + val restoredHighestIndexes = allocationState?.highestAccountIndexByWallet.orEmpty() + val mergedHighestIndexes = restoredHighestIndexes.entries + .fold(highestAccountIndexByWallet) { indexes, (wallet, index) -> + indexes + (wallet to maxOf(indexes[wallet] ?: 0, index)) + }.withAccountIndexes(accounts) + val mergedPendingAllocations = allocationState?.pendingAccountIndexByRequest.orEmpty() + + return copy( + accounts = accounts.sortedBy(WatchOnlyAccountRecord::accountIndex), + highestAccountIndexByWallet = mergedHighestIndexes, + pendingAccountIndexByRequest = mergedPendingAllocations, + ) +} + +private fun Map.withAccountIndexes(accounts: List): Map { + val updated = toMutableMap() + accounts.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 index 6b68da6ddb..997f90828d 100644 --- a/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt +++ b/app/src/main/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializer.kt @@ -1,23 +1,20 @@ 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 to.bitkit.utils.Logger import java.io.InputStream import java.io.OutputStream object WatchOnlyAccountDataSerializer : Serializer { - private const val TAG = "WatchOnlyAccountDataSerializer" - override val defaultValue = WatchOnlyAccountData() override suspend fun readFrom(input: InputStream): WatchOnlyAccountData = try { json.decodeFromString(input.readBytes().decodeToString()) } catch (error: SerializationException) { - Logger.error("Deserialize watch-only account data failed", error, context = TAG) - defaultValue + throw CorruptionException("Failed to deserialize watch-only account data", error) } override suspend fun writeTo(t: WatchOnlyAccountData, output: OutputStream) { diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index d54f99cc50..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 @@ -22,6 +23,7 @@ data class WalletBackupV1( 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 3e15b9b5dc..68bdd20a10 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -21,11 +21,12 @@ enum class PubkyAuthClaim(val wireValue: String) { } } -sealed class PubkyAuthRequestError(message: String, cause: Throwable? = null) : AppError(message, cause) { - class InvalidUrl(cause: Throwable) : PubkyAuthRequestError("Invalid Pubky auth URL", cause) - data object DuplicateBitkitClaim : PubkyAuthRequestError("Duplicate Bitkit claim") - data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError("Unsupported Bitkit claim '$value'") - data object InvalidBitkitClaimCapabilities : PubkyAuthRequestError("Invalid capabilities for Bitkit claim") +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 @@ -91,6 +92,8 @@ data class PubkyAuthRequest( 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) } diff --git a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt index 9fc892f7a1..a20f3e6cb8 100644 --- a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt +++ b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt @@ -1,20 +1,27 @@ 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 + @Serializable enum class WatchOnlyAccountSetupState { @SerialName("pendingDelivery") PendingDelivery, + @SerialName("authorizing") + Authorizing, + @SerialName("active") Active, } @Serializable +@Immutable data class WatchOnlyAccountRecord( val id: String, val walletIndex: Int, diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 5cf69a1f61..23c26d05a2 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -267,7 +267,6 @@ class BackupRepo @Inject constructor( val watchOnlyAccountsJob = scope.launch { watchOnlyAccountStore.data - .map { it.accounts } .distinctUntilChanged() .drop(1) .collect { @@ -559,17 +558,20 @@ class BackupRepo @Inject constructor( } .getOrThrow() + val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val payload = WalletBackupV1( createdAt = currentTimeMillis(), transfers = transfers, privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, paykitSdkBackupState = paykitSdkBackupState, - watchOnlyAccounts = watchOnlyAccountStore.load(), + watchOnlyAccounts = watchOnlyAccountSnapshot.accounts, + watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, ) return json.encodeToString(payload).toByteArray() } + @Suppress("LongMethod") suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, ): Result = withContext(ioDispatcher) { @@ -646,7 +648,11 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) - watchOnlyAccountStore.save(parsed.watchOnlyAccounts.orEmpty()) + watchOnlyAccountStore.restore( + parsed.watchOnlyAccounts.orEmpty(), + parsed.watchOnlyAccountAllocationState, + ) + lightningService.reconcileWatchOnlyAccounts(parsed.watchOnlyAccounts.orEmpty()) 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..2c0eb37bc9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -73,6 +73,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 +89,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 +340,9 @@ class LightningRepo @Inject constructor( } } - if (getStatus()?.isRunning == true) { + if (lightningService.status?.isRunning == true) { Logger.info("LDK node already running", context = TAG) + lightningService.reconcileWatchOnlyAccounts() _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 ee707da467..be89220b4e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -40,6 +40,7 @@ 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 @@ -918,6 +919,25 @@ 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, + 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 index 42f1abf154..121f12cefa 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -1,25 +1,26 @@ 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.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters -import org.bouncycastle.crypto.signers.Ed25519Signer +import org.bitcoinj.base.Base58 import org.lightningdevkit.ldknode.AddressType import to.bitkit.async.ServiceQueue import to.bitkit.data.WatchOnlyAccountStore -import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher -import to.bitkit.ext.fromHex +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.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.services.LightningService import to.bitkit.utils.AppError -import java.math.BigInteger import java.net.URI import java.net.URLDecoder import java.nio.ByteBuffer @@ -29,48 +30,36 @@ import java.util.Base64 import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.ExperimentalTime -sealed class WatchOnlyAccountError(message: String) : AppError(message) { - data object InvalidAccountName : WatchOnlyAccountError("Enter an account name between 1 and 64 characters.") - data object InvalidAuthRequest : WatchOnlyAccountError("This authorization request is missing a valid secret.") - data object InvalidExtendedPublicKey : WatchOnlyAccountError("Bitkit could not create a valid account xpub.") - data object NodeUnavailable : WatchOnlyAccountError("The wallet must be running before an account can be created.") - data object CompanionTransportUnavailable : WatchOnlyAccountError( - "This request needs the signed watch-only claim transport from the Paykit SDK before it can be authorized." - ) -} - -@Singleton -class WatchOnlyAccountClaimTransport @Inject constructor() { - suspend fun deliver( - @Suppress("UNUSED_PARAMETER") payload: ByteArray, - @Suppress("UNUSED_PARAMETER") authUrl: String, - ) { - throw WatchOnlyAccountError.CompanionTransportUnavailable - } +sealed class WatchOnlyAccountError : AppError() { + data object InvalidAccountName : WatchOnlyAccountError() + data object InvalidExtendedPublicKey : WatchOnlyAccountError() + data object NodeUnavailable : WatchOnlyAccountError() } @Singleton +@OptIn(ExperimentalTime::class) class WatchOnlyAccountRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val store: WatchOnlyAccountStore, private val lightningService: LightningService, - private val keychain: Keychain, - private val transport: WatchOnlyAccountClaimTransport, ) { private val mutationMutex = Mutex() val accounts: Flow> = store.data.map { it.accounts } - suspend fun prepareSignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim = + suspend fun prepareUnsignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim = withContext(bgDispatcher) { mutationMutex.withLock { val normalizedName = normalizeName(name) val walletIndex = lightningService.currentWalletIndex - val fingerprint = sha256(authUrl.encodeToByteArray()).toBase64() + val fingerprint = requestFingerprint(authUrl) val current = store.load() current.firstOrNull { - it.walletIndex == walletIndex && it.requestFingerprint == fingerprint + it.walletIndex == walletIndex && + it.requestFingerprint == fingerprint && + it.setupState != WatchOnlyAccountSetupState.Active }?.let { existing -> val refreshed = existing.copy(name = normalizedName) if (refreshed != existing) { @@ -78,16 +67,12 @@ class WatchOnlyAccountRepo @Inject constructor( } return@withLock PreparedWatchOnlyAccountClaim( account = refreshed, - payload = WatchOnlyAccountClaimCodec.encode(refreshed, authUrl, localSecretKey()), + payload = WatchOnlyAccountClaimCodec.encode(refreshed), ) } - val highestAccountIndex = current - .filter { it.walletIndex == walletIndex } - .maxOfOrNull(WatchOnlyAccountRecord::accountIndex) ?: 0 - check(highestAccountIndex < Int.MAX_VALUE) { "Watch-only account index overflow" } - val accountIndex = highestAccountIndex + 1 - val xpub = createAndTrackAccount(accountIndex) + val accountIndex = store.reserveAccountIndex(walletIndex, fingerprint) + val xpub = exportAccountXpub(accountIndex) val account = WatchOnlyAccountRecord( id = UUID.randomUUID().toString(), walletIndex = walletIndex, @@ -95,49 +80,147 @@ class WatchOnlyAccountRepo @Inject constructor( addressType = ADDRESS_TYPE_NATIVE_SEGWIT, xpub = xpub, requestFingerprint = fingerprint, - createdAt = System.currentTimeMillis(), + createdAt = nowMillis(), name = normalizedName, - isTrackingEnabled = true, + isTrackingEnabled = false, setupState = WatchOnlyAccountSetupState.PendingDelivery, ) store.save(current + account) PreparedWatchOnlyAccountClaim( account = account, - payload = WatchOnlyAccountClaimCodec.encode(account, authUrl, localSecretKey()), + payload = WatchOnlyAccountClaimCodec.encode(account), ) } } - suspend fun deliver(prepared: PreparedWatchOnlyAccountClaim, authUrl: String) { - transport.deliver(prepared.payload, authUrl) + suspend fun markActive(id: String) { + mutationMutex.withLock { + store.markActive(id) + } } - suspend fun markActive(id: String) = updateAccount(id) { it.copy(setupState = WatchOnlyAccountSetupState.Active) } + suspend fun beginAuthorization(id: String) = withContext(NonCancellable) { + mutationMutex.withLock { + val account = store.load().firstOrNull { + it.id == id && it.setupState != WatchOnlyAccountSetupState.Active + } ?: return@withLock + 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 = false) } + } + updateResult.getOrThrow() + } + } + + suspend fun cancelAuthorization(id: String) = withContext(NonCancellable) { + mutationMutex.withLock { + val current = store.load() + val account = current.firstOrNull { + it.id == id && it.setupState != WatchOnlyAccountSetupState.Active + } ?: return@withLock + store.save( + current.map { + if (it.id == id) { + it.copy( + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + } else { + it + } + }, + ) + setAccountTracking(account, enabled = false) + } + } suspend fun rename(id: String, name: String) { val normalizedName = normalizeName(name) updateAccount(id) { it.copy(name = normalizedName) } } - suspend fun setTrackingEnabled(id: String, enabled: Boolean) = - updateAccount(id) { it.copy(isTrackingEnabled = enabled) } + suspend fun setTrackingEnabled(id: String, enabled: Boolean) = withContext(NonCancellable) { + mutationMutex.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?) { - store.save(accounts.orEmpty()) + store.restore(accounts.orEmpty()) + } + + 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 createAndTrackAccount(accountIndex: Int): String = ServiceQueue.LDK.background { + private suspend fun setAccountTracking( + account: WatchOnlyAccountRecord, + enabled: Boolean, + ) = ServiceQueue.LDK.background { val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable - val index = accountIndex.toUInt() - val xpub = node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, index) - if (node.listOnchainWalletAccounts().none { - it.addressType == AddressType.NATIVE_SEGWIT && it.accountIndex == index + val addressType = when (account.addressType) { + ADDRESS_TYPE_NATIVE_SEGWIT -> 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() } - ) { - node.addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, index, xpub) + !enabled && isTracked -> node.removeOnchainWalletAccount(addressType, accountIndex) } - node.syncWallets() - xpub } private suspend fun updateAccount(id: String, transform: (WatchOnlyAccountRecord) -> WatchOnlyAccountRecord) { @@ -146,10 +229,6 @@ class WatchOnlyAccountRepo @Inject constructor( } } - private fun localSecretKey(): ByteArray = requireNotNull( - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)?.takeIf(String::isNotBlank) - ) { "No local Pubky identity is available" }.fromHex() - private fun normalizeName(name: String): String { val normalized = name.trim() if (normalized.isEmpty() || normalized.length > MAX_NAME_LENGTH) { @@ -158,80 +237,69 @@ class WatchOnlyAccountRepo @Inject constructor( 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 { const val ADDRESS_TYPE_NATIVE_SEGWIT = "nativeSegwit" 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 = 78 - const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH + 64 + const val PAYLOAD_LENGTH = 1 + 4 + 1 + SERIALIZED_XPUB_LENGTH - private val signatureDomain = "x-bitkit-claim|watch-only-account-v1|".encodeToByteArray() - private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - private const val BASE58_RADIX = 58L - private const val BASE58_CHECKSUM_LENGTH = 4 - - fun encode(account: WatchOnlyAccountRecord, authUrl: String, secretKey: ByteArray): ByteArray { - if (account.addressType != WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT || secretKey.size != 32) { - throw WatchOnlyAccountError.InvalidExtendedPublicKey + fun encode(account: WatchOnlyAccountRecord): ByteArray { + val rawXpub = if (account.addressType == WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT) { + runCatching { Base58.decodeChecked(account.xpub) }.getOrNull() + } else { + null } - val rawXpub = decodeBase58Check(account.xpub) - if (rawXpub.size != SERIALIZED_XPUB_LENGTH) throw WatchOnlyAccountError.InvalidExtendedPublicKey + if (rawXpub?.size != SERIALIZED_XPUB_LENGTH) throw WatchOnlyAccountError.InvalidExtendedPublicKey - val unsignedClaim = ByteBuffer.allocate(1 + 4 + 1 + SERIALIZED_XPUB_LENGTH) + return ByteBuffer.allocate(PAYLOAD_LENGTH) .put(VERSION) .putInt(account.accountIndex) .put(NATIVE_SEGWIT_ADDRESS_TYPE) .put(rawXpub) .array() - val signable = signatureDomain + requestSecretHash(authUrl) + unsignedClaim - val signer = Ed25519Signer().apply { - init(true, Ed25519PrivateKeyParameters(secretKey, 0)) - update(signable, 0, signable.size) - } - return unsignedClaim + signer.generateSignature() - } - - fun requestSecretHash(authUrl: String): ByteArray { - val rawQuery = runCatching { URI(authUrl).rawQuery }.getOrNull() - ?: throw WatchOnlyAccountError.InvalidAuthRequest - val secrets = rawQuery.split('&').mapNotNull { item -> - val parts = item.split('=', limit = 2) - if (percentDecode(parts[0]) != "secret") return@mapNotNull null - percentDecode(parts.getOrElse(1) { "" }) - } - if (secrets.size != 1 || secrets.first().isEmpty()) throw WatchOnlyAccountError.InvalidAuthRequest - return sha256(secrets.first().encodeToByteArray()) } - - private fun decodeBase58Check(value: String): ByteArray { - if (value.isEmpty()) invalidExtendedPublicKey() - var number = BigInteger.ZERO - value.forEach { character -> - val digit = BASE58_ALPHABET.indexOf(character) - if (digit < 0) invalidExtendedPublicKey() - number = number.multiply(BigInteger.valueOf(BASE58_RADIX)).add(BigInteger.valueOf(digit.toLong())) - } - val magnitude = number.toByteArray().let { - if (it.size > 1 && it[0] == 0.toByte()) it.drop(1).toByteArray() else it - } - val decoded = ByteArray(value.takeWhile { it == '1' }.length) + magnitude - if (decoded.size <= BASE58_CHECKSUM_LENGTH) invalidExtendedPublicKey() - val payload = decoded.copyOfRange(0, decoded.size - BASE58_CHECKSUM_LENGTH) - val checksum = decoded.copyOfRange(decoded.size - BASE58_CHECKSUM_LENGTH, decoded.size) - val expected = sha256(sha256(payload)).copyOf(BASE58_CHECKSUM_LENGTH) - if (!MessageDigest.isEqual(checksum, expected)) invalidExtendedPublicKey() - return payload - } - - private fun invalidExtendedPublicKey(): Nothing = throw WatchOnlyAccountError.InvalidExtendedPublicKey - - private fun percentDecode(value: String): String = - URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8) } private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 210c941469..a8af988a88 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 @@ -51,9 +52,13 @@ 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.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.utils.AppError @@ -73,6 +78,31 @@ 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) { + "nativeSegwit" -> 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, @@ -144,37 +174,13 @@ class LightningService @Inject constructor( config, channelMigration, ) - registerEnabledWatchOnlyAccounts(builtNode, walletIndex) currentWalletIndex = walletIndex node = builtNode Logger.info("LDK node setup", context = TAG) } - private suspend fun registerEnabledWatchOnlyAccounts(node: Node, walletIndex: Int) { - val records = watchOnlyAccountStore.load().filter { - it.walletIndex == walletIndex && it.isTrackingEnabled - } - if (records.isEmpty()) return - - ServiceQueue.LDK.background { - val registered = node.listOnchainWalletAccounts() - .map { it.addressType to it.accountIndex } - .toMutableSet() - records.forEach { record -> - val addressType = when (record.addressType) { - "nativeSegwit" -> LdkAddressType.NATIVE_SEGWIT - else -> throw IllegalArgumentException("Unsupported watch-only account address type") - } - val accountIndex = record.accountIndex.toUInt() - if (registered.add(addressType to accountIndex)) { - node.addOnchainWalletAccount(addressType, accountIndex, record.xpub) - } - } - } - } - - private fun config( + private suspend fun config( walletIndex: Int, trustedPeers: List?, ): Config { @@ -196,6 +202,7 @@ class LightningService @Inject constructor( ), probingLiquidityLimitMultiplier = 1uL, includeUntrustedPendingInSpendable = true, + onchainWalletAccounts = enabledOnchainWalletAccountConfigs(watchOnlyAccountStore.load(), walletIndex), ) } @@ -307,6 +314,8 @@ class LightningService @Inject constructor( feeRateCacheUpdateIntervalSecs = Env.walletSyncIntervalSecs, ), connectionTimeoutSecs = Env.walletSyncTimeoutSecs, + additionalWalletFullScanBatchSize = 100u, + additionalWalletFullScanStopGap = 1000u, ), ) } @@ -324,6 +333,8 @@ class LightningService @Inject constructor( } } + reconcileWatchOnlyAccountsBestEffort() + // start event listener after node started onEvent?.let { eventHandler -> shouldListenForEvents = true @@ -346,6 +357,59 @@ 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( + records: List? = null, + syncAfterReconcile: Boolean = true, + ) { + val node = node ?: return + val walletRecords = (records ?: watchOnlyAccountStore.load()).filter { it.walletIndex == currentWalletIndex } + val desiredConfigs = enabledOnchainWalletAccountConfigs(walletRecords, currentWalletIndex) + + ServiceQueue.LDK.background { + val trackedAccounts = node.listOnchainWalletAccounts() + val managedKeys = walletRecords.mapNotNull { record -> + when (record.addressType) { + "nativeSegwit" -> 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() + } + } + } + suspend fun stop() { shouldListenForEvents = false listenerJob?.cancelAndJoin() @@ -448,6 +512,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 7df973096f..c30bb05466 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..e91106327d 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -3,13 +3,14 @@ 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 import javax.inject.Inject import javax.inject.Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Singleton class PubkyService @Inject constructor( private val paykitSdkService: PaykitSdkService, @@ -117,6 +118,26 @@ class PubkyService @Inject constructor( paykitSdkService.approveAuth(authUrl, expectedCapabilities, secretKeyHex) } + suspend fun approveAuthWithCompanionClaim( + authUrl: String, + expectedCapabilities: String, + secretKeyHex: String, + queryParameter: String, + claimType: String, + unsignedPayload: ByteArray, + ) = ServiceQueue.CORE.background { + paykitSdkService.approveAuthWithCompanionClaim( + authUrl = authUrl, + expectedCapabilities = expectedCapabilities, + secretKeyHex = secretKeyHex, + claim = PubkyAuthCompanionClaim( + queryParameter = queryParameter, + claimType = claimType, + unsignedPayload = unsignedPayload, + ), + ) + } + // endregion // region File operations 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 2e8bf0aad4..98074cfdcd 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,7 +11,9 @@ 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -113,7 +115,10 @@ fun PubkyAuthApprovalSheet( Box { Content( uiState = uiState, - onAuthorize = { viewModel.requestAuthorize(authUrl) }, + isCurrentRequest = uiState.authUrl == authUrl, + onAuthorize = { + if (uiState.authUrl == authUrl) viewModel.requestAuthorize(authUrl) + }, onAccountNameChange = viewModel::updateWatchOnlyAccountName, onCancel = { viewModel.dismiss() }, onDismiss = { viewModel.dismiss() }, @@ -170,12 +175,14 @@ internal fun resolvePubkyApprovalLocalAuthMode( @Composable private fun Content( uiState: PubkyAuthApprovalUiState, + isCurrentRequest: Boolean, onAuthorize: () -> Unit, onAccountNameChange: (String) -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, ) { - val headerTitle = if (uiState.state == ApprovalState.Success) { + val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading + val headerTitle = if (approvalState == ApprovalState.Success) { stringResource(R.string.profile__auth_approval_success) } else { stringResource(R.string.profile__auth_approval_title) @@ -190,7 +197,7 @@ private fun Content( ) { SheetTopBar(titleText = headerTitle) - when (uiState.state) { + when (approvalState) { ApprovalState.Loading -> LoadingContent() ApprovalState.Authorize -> AuthorizeContent( uiState = uiState, @@ -228,30 +235,11 @@ private fun ColumnScope.AuthorizeContent( onAccountNameChange: (String) -> Unit, onCancel: () -> Unit, ) { - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(32.dp) - - PermissionsSection(permissions = uiState.permissions) - VerticalSpacer(16.dp) - - uiState.bitkitClaim?.let { - BitkitClaimSection(it) - VerticalSpacer(16.dp) - WatchOnlyAccountName( - value = uiState.watchOnlyAccountName, - enabled = true, - onValueChange = onAccountNameChange, - ) - VerticalSpacer(16.dp) - } - - FillHeight() - - TrustWarning() - VerticalSpacer(16.dp) - - uiState.profile?.let { ProfileCard(it) } - VerticalSpacer(24.dp) + ApprovalDetails( + uiState = uiState, + accountNameEnabled = true, + onAccountNameChange = onAccountNameChange, + ) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( @@ -272,30 +260,11 @@ private fun ColumnScope.AuthorizeContent( private fun ColumnScope.AuthorizingContent( uiState: PubkyAuthApprovalUiState, ) { - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(32.dp) - - PermissionsSection(permissions = uiState.permissions) - VerticalSpacer(16.dp) - - uiState.bitkitClaim?.let { - BitkitClaimSection(it) - VerticalSpacer(16.dp) - WatchOnlyAccountName( - value = uiState.watchOnlyAccountName, - enabled = false, - onValueChange = {}, - ) - VerticalSpacer(16.dp) - } - - FillHeight() - - TrustWarning() - VerticalSpacer(16.dp) - - uiState.profile?.let { ProfileCard(it) } - VerticalSpacer(24.dp) + ApprovalDetails( + uiState = uiState, + accountNameEnabled = false, + onAccountNameChange = {}, + ) PrimaryButton( text = stringResource(R.string.profile__auth_approval_authorizing), @@ -306,6 +275,42 @@ private fun ColumnScope.AuthorizingContent( VerticalSpacer(16.dp) } +@Composable +private fun ColumnScope.ApprovalDetails( + uiState: PubkyAuthApprovalUiState, + accountNameEnabled: Boolean, + onAccountNameChange: (String) -> Unit, +) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + DescriptionText(serviceName = uiState.serviceName) + VerticalSpacer(32.dp) + + PermissionsSection(permissions = uiState.permissions) + VerticalSpacer(16.dp) + + uiState.bitkitClaim?.let { + BitkitClaimSection(it) + VerticalSpacer(16.dp) + WatchOnlyAccountName( + value = uiState.watchOnlyAccountName, + enabled = accountNameEnabled, + onValueChange = onAccountNameChange, + ) + VerticalSpacer(16.dp) + } + + TrustWarning() + VerticalSpacer(16.dp) + + uiState.profile?.let { ProfileCard(it) } + VerticalSpacer(24.dp) + } +} + @Composable private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, @@ -489,6 +494,7 @@ private fun AuthorizePreview() { status = null, ), ), + isCurrentRequest = true, onAuthorize = {}, onAccountNameChange = {}, onCancel = {}, @@ -508,6 +514,7 @@ private fun SuccessPreview() { state = ApprovalState.Success, serviceName = "pubky.app", ), + isCurrentRequest = true, onAuthorize = {}, onAccountNameChange = {}, onCancel = {}, 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 fb78176b76..a3f68d5aae 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,6 +17,7 @@ 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.PubkyProfile @@ -23,6 +25,7 @@ import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.utils.Logger import javax.inject.Inject @@ -43,17 +46,20 @@ class PubkyAuthApprovalViewModel @Inject constructor( val effects = _effects.asSharedFlow() fun load(authUrl: String) { + _uiState.value = PubkyAuthApprovalUiState(authUrl = authUrl) viewModelScope.launch { 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 } + if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) val serviceName = request.serviceNames.firstOrNull() ?: unknownService val profile = pubkyRepo.profile.value @@ -78,6 +84,8 @@ class PubkyAuthApprovalViewModel @Inject constructor( } fun requestAuthorize(authUrl: String) { + val state = _uiState.value + if (state.authUrl != authUrl || state.state != ApprovalState.Authorize) return viewModelScope.launch { _effects.emit(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl)) } @@ -88,56 +96,86 @@ class PubkyAuthApprovalViewModel @Inject constructor( } fun confirmAuthorize(authUrl: String) { + if (!transitionToAuthorizing(authUrl)) return + 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 + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + handleApprovalFailure(it, authUrl) + return@launch } + if (_uiState.value.authUrl != authUrl) return@launch - val preparedClaim = runCatching { - if (_uiState.value.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { - watchOnlyAccountRepo.prepareSignedClaim(authUrl, _uiState.value.watchOnlyAccountName).also { - watchOnlyAccountRepo.deliver(it, authUrl) - } + val preparedClaim = runSuspendCatching { + if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, _uiState.value.watchOnlyAccountName) } else { null } }.getOrElse { - handleApprovalFailure(it) + handleApprovalFailure(it, authUrl) return@launch } - val approvalResult = pubkyRepo.approveAuth(authUrl, capabilities) + preparedClaim?.let { claim -> + runSuspendCatching { watchOnlyAccountRepo.beginAuthorization(claim.account.id) }.getOrElse { + cancelIncompleteSetup(claim.account.id) + handleApprovalFailure(it, authUrl) + return@launch + } + } + + val approvalResult = preparedClaim?.let { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) + } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) if (approvalResult.isFailure) { - handleApprovalFailure(approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed")) + val approvalError = approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed") + preparedClaim?.let { claim -> + if (!approvalError.isPostDeliveryAuthorizationFailure()) { + cancelIncompleteSetup(claim.account.id) + } + } + handleApprovalFailure(approvalError, authUrl) return@launch } preparedClaim?.let { claim -> - runCatching { watchOnlyAccountRepo.markActive(claim.account.id) } - .onFailure { Logger.error("Failed to mark watch-only account active", it, context = TAG) } + runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { + handleApprovalFailure(it, authUrl) + return@launch + } + } + 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 } - Logger.info("Auth approved for '${_uiState.value.serviceName}'", context = TAG) - _uiState.update { it.copy(state = ApprovalState.Success) } } } - private suspend fun handleApprovalFailure(error: Throwable) { + private fun transitionToAuthorizing(authUrl: String): Boolean { + val initialState = _uiState.value + if (initialState.authUrl != authUrl || initialState.state != ApprovalState.Authorize) return false + return _uiState.compareAndSet(initialState, initialState.copy(state = ApprovalState.Authorizing)) + } + + private suspend fun cancelIncompleteSetup(accountId: String) { + runSuspendCatching { watchOnlyAccountRepo.cancelAuthorization(accountId) } + .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.message, + description = error.localizedPubkyAuthMessage(context), ) } @@ -148,6 +186,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( @Stable data class PubkyAuthApprovalUiState( + val authUrl: String = "", val state: ApprovalState = ApprovalState.Loading, val serviceName: String = "", val requestedCapabilities: String = "", @@ -168,3 +207,12 @@ sealed interface PubkyAuthApprovalEffect { data class RequestLocalAuth(val authUrl: String) : PubkyAuthApprovalEffect data object Dismiss : PubkyAuthApprovalEffect } + +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/advanced/WatchOnlyAccountsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt index 6f2977d85f..c27dbafd9d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt @@ -1,23 +1,21 @@ package to.bitkit.ui.settings.advanced -import androidx.compose.foundation.background 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.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Switch +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -27,6 +25,7 @@ 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 @@ -34,13 +33,18 @@ 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 @@ -63,19 +67,22 @@ fun WatchOnlyAccountsScreen( @Composable private fun Content( - accounts: List, + accounts: ImmutableList, updatingAccountId: String?, 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( - verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), @@ -85,51 +92,104 @@ private fun Content( text = stringResource(R.string.watch_only_accounts__description), color = Colors.White64, ) + VerticalSpacer(8.dp) } if (accounts.isEmpty()) { item { EmptyState() } } else { - items(accounts, key = WatchOnlyAccountRecord::id) { account -> - AccountCard( - account = account, - isUpdating = updatingAccountId != null, - onRename = onRename, - onTrackingChange = onTrackingChange, - ) + if (activeAccounts.isNotEmpty()) { + item { + SectionHeader(stringResource(R.string.watch_only_accounts__active_section)) + } + items(activeAccounts, key = WatchOnlyAccountRecord::id) { account -> + ActiveAccountRows( + account = account, + isUpdating = updatingAccountId != null, + 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) } } } -} -@Composable -private fun EmptyState() { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Colors.Gray6, RoundedCornerShape(16.dp)) - .padding(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, + selectedAccount?.let { account -> + AccountDetailsSheet( + account = account, + onRename = { name -> onRename(account, name) }, + onDismiss = { selectedAccount = null }, ) } } @Composable -private fun AccountCard( +private fun ActiveAccountRows( account: WatchOnlyAccountRecord, isUpdating: Boolean, - onRename: (WatchOnlyAccountRecord, String) -> Unit, + onOpenDetails: () -> Unit, onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, ) { - var name by remember(account.id) { mutableStateOf(account.name) } - LaunchedEffect(account.name) { name = account.name } + 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) { @@ -139,28 +199,28 @@ private fun AccountCard( ) } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .background(Colors.Gray6, RoundedCornerShape(16.dp)) - .padding(20.dp) - .testTag("WatchOnlyAccount_${account.accountIndex}"), + BottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.imePadding(), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - BodySSB(account.name) - BodyM(account.derivationPath, color = Colors.White64) + 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) } - Switch( - checked = account.isTrackingEnabled, - onCheckedChange = { onTrackingChange(account, it) }, - enabled = !isUpdating, - modifier = Modifier.testTag("WatchOnlyAccountTracking_${account.accountIndex}"), - ) - } - Column { Caption13Up(stringResource(R.string.watch_only_accounts__name), color = Colors.White64) VerticalSpacer(8.dp) TextInput( @@ -172,45 +232,55 @@ private fun AccountCard( .fillMaxWidth() .testTag("WatchOnlyAccountName_${account.accountIndex}"), ) - } - Column { + 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 = 2, + maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag("WatchOnlyAccountXpub_${account.accountIndex}"), ) - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - SecondaryButton( - text = stringResource(R.string.watch_only_accounts__save_name), - onClick = { onRename(account, 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) + 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) } + } +} - if (account.setupState == WatchOnlyAccountSetupState.PendingDelivery) { - BodyM( - text = stringResource(R.string.watch_only_accounts__pending_delivery), - color = Colors.Yellow, - modifier = Modifier.testTag("WatchOnlyAccountPending_${account.accountIndex}"), - ) - } +@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 index abe0fcde28..a3c49506f9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt @@ -5,26 +5,29 @@ 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.LightningRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.services.LightningService 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, - private val lightningRepo: LightningRepo, lightningService: LightningService, ) : ViewModel() { private val walletIndex = lightningService.currentWalletIndex @@ -32,12 +35,12 @@ class WatchOnlyAccountsViewModel @Inject constructor( val updatingAccountId = _updatingAccountId.asStateFlow() val accounts = watchOnlyAccountRepo.accounts - .map { accounts -> accounts.filter { it.walletIndex == walletIndex } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .map { accounts -> accounts.filter { it.walletIndex == walletIndex }.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) fun rename(account: WatchOnlyAccountRecord, name: String) { viewModelScope.launch { - runCatching { watchOnlyAccountRepo.rename(account.id, name) } + runSuspendCatching { watchOnlyAccountRepo.rename(account.id, name) } .onSuccess { ToastEventBus.send( type = Toast.ToastType.SUCCESS, @@ -50,29 +53,25 @@ class WatchOnlyAccountsViewModel @Inject constructor( fun setTrackingEnabled(account: WatchOnlyAccountRecord, enabled: Boolean) { viewModelScope.launch { - _updatingAccountId.value = account.id - runCatching { - watchOnlyAccountRepo.setTrackingEnabled(account.id, enabled) - lightningRepo.restartNode().getOrThrow() - }.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 -> - runCatching { - watchOnlyAccountRepo.setTrackingEnabled(account.id, account.isTrackingEnabled) - lightningRepo.restartNode().getOrThrow() - } - showError(error) + _updatingAccountId.update { account.id } + 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 { + _updatingAccountId.update { null } } - _updatingAccountId.value = null } } @@ -80,7 +79,7 @@ class WatchOnlyAccountsViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = error.message, + 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..79108e356c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -0,0 +1,28 @@ +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.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 dd5bab9aa4..1ca8fb2c2b 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -68,7 +68,7 @@ class WipeWalletUseCase @Inject constructor( settingsStore.reset() cacheStore.reset() - watchOnlyAccountStore.save(emptyList()) + watchOnlyAccountStore.clear() widgetsStore.reset() blocktankRepo.resetState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a794402ba1..f6318c3055 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,25 +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 Share a watch-only Bitcoin account. This lets the service generate receive addresses and view this account’s transactions and balance, but not spend funds. ACCOUNT NAME Name this account - %1$s server Watch-only Bitcoin account - Server accounts - Each approved service gets a separate Bitcoin account. Turning tracking off rebuilds the wallet without that account; it does not revoke the service session. - No server accounts - Accounts you approve for Paykit servers will appear here. - NAME - Account name - EXTENDED PUBLIC KEY - Save name - Copy xpub - Claim delivery is pending. - Account name saved - Account tracking enabled - Account tracking disabled + 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. @@ -1263,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..0fe5528caf --- /dev/null +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -0,0 +1,127 @@ +package to.bitkit.data + +import org.junit.Test +import to.bitkit.di.json +import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WatchOnlyAccountRecord +import to.bitkit.models.WatchOnlyAccountSetupState +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchOnlyAccountStoreTest { + @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")) + + data = data.completeAccountIndexAllocation("0:first") + + assertEquals(3, reserve("first")) + assertEquals(3, 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 does not lower the allocator high water mark`() { + val data = WatchOnlyAccountData( + highestAccountIndexByWallet = mapOf("0" to 10), + pendingAccountIndexByRequest = mapOf("0:pending" to 10), + ) + + val restored = data.restoreAccounts(listOf(account(accountIndex = 7))) + val reservation = restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "next") + + assertEquals(emptyMap(), restored.pendingAccountIndexByRequest) + assertEquals(11, reservation.accountIndex) + } + + @Test + fun `restoring allocator backup preserves pending reuse and monotonic high water mark`() { + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 9), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + + val restored = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(account(accountIndex = 5)), + allocationState = allocationState, + ) + + assertEquals(7, restored.reserveAccountIndex(0, "pending").accountIndex) + assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex) + assertEquals(allocationState.pendingAccountIndexByRequest, restored.pendingAccountIndexByRequest) + } + + @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"]) + assertEquals(7, activated.highestAccountIndexByWallet["0"]) + assertTrue(requestKey in data.pendingAccountIndexByRequest) + } + + private fun account(accountIndex: Int) = WatchOnlyAccountRecord( + id = "account-$accountIndex", + walletIndex = 0, + accountIndex = accountIndex, + addressType = "nativeSegwit", + xpub = "xpub", + requestFingerprint = "request-$accountIndex", + createdAt = 1, + name = "Account $accountIndex", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Active, + ) +} 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 d337e10278..e53432f5dd 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -31,6 +31,18 @@ class PubkyAuthRequestTest { 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 diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 0f57ef2df6..2e28fe0ccc 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -13,22 +13,26 @@ 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.WidgetsData -import to.bitkit.data.WidgetsStore +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 import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.dao.TransferDao @@ -37,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 @@ -86,6 +93,9 @@ class BackupRepoTest : BaseUnitTest() { 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))) @@ -256,6 +266,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 storeWasRestored = false + whenever { watchOnlyAccountStore.restore(listOf(account), allocationState) }.thenAnswer { + storeWasRestored = true + Unit + } + whenever { lightningService.reconcileWatchOnlyAccounts(listOf(account)) }.thenAnswer { + assertTrue(storeWasRestored) + Unit + } + + val result = sut.performFullRestoreFromLatestBackup() + + assertTrue(result.isSuccess) + verifyBlocking(watchOnlyAccountStore) { restore(listOf(account), allocationState) } + verifyBlocking(lightningService) { reconcileWatchOnlyAccounts(listOf(account)) } + } + @Test fun `full restore should fail when private Paykit reserved indexes fail to reconcile`() = test { stubWalletBackup() @@ -270,12 +330,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( @@ -343,4 +407,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..30da57aeda 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,24 @@ 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 `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..49222559ef 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -29,6 +29,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 +207,28 @@ 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, + 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 index 3eb11218c7..8bcdcd16f5 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -1,52 +1,40 @@ package to.bitkit.repositories -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters -import org.bouncycastle.crypto.signers.Ed25519Signer +import org.bitcoinj.base.Base58 import org.junit.Test import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState -import java.math.BigInteger import java.nio.ByteBuffer import java.security.MessageDigest import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlin.test.assertFailsWith class WatchOnlyAccountClaimCodecTest { @Test - fun `signed claim contains account metadata and request-bound signature`() { + fun `unsigned claim contains exact account metadata`() { val rawXpub = ByteArray(WatchOnlyAccountClaimCodec.SERIALIZED_XPUB_LENGTH) { it.toByte() } - val privateKeyBytes = ByteArray(32) { 7 } - val authUrl = "pubkyauth://signin?secret=request-secret" val account = account(accountIndex = 42, xpub = base58CheckEncode(rawXpub)) - val payload = WatchOnlyAccountClaimCodec.encode(account, authUrl, privateKeyBytes) + val payload = WatchOnlyAccountClaimCodec.encode(account) + 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)) - - val unsignedClaim = payload.copyOfRange(0, 84) - val signature = payload.copyOfRange(84, payload.size) - val signable = "x-bitkit-claim|watch-only-account-v1|".encodeToByteArray() + - WatchOnlyAccountClaimCodec.requestSecretHash(authUrl) + - unsignedClaim - val verifier = Ed25519Signer().apply { - init(false, Ed25519PrivateKeyParameters(privateKeyBytes, 0).generatePublicKey()) - update(signable, 0, signable.size) - } - assertTrue(verifier.verifySignature(signature)) } @Test - fun `request secret hashing percent decodes without treating plus as a space`() { - val encoded = WatchOnlyAccountClaimCodec.requestSecretHash( - "pubkyauth://signin?secret=one%2Ftwo+three", - ) + fun `unsigned claim rejects invalid Base58Check checksum`() { + val rawXpub = ByteArray(WatchOnlyAccountClaimCodec.SERIALIZED_XPUB_LENGTH) { it.toByte() } + val validXpub = base58CheckEncode(rawXpub) + val invalidXpub = validXpub.dropLast(1) + if (validXpub.last() == '1') '2' else '1' - assertContentEquals(sha256("one/two+three".encodeToByteArray()), encoded) + assertFailsWith { + WatchOnlyAccountClaimCodec.encode(account(accountIndex = 1, xpub = invalidXpub)) + } } private fun account(accountIndex: Int, xpub: String) = WatchOnlyAccountRecord( @@ -64,22 +52,8 @@ class WatchOnlyAccountClaimCodecTest { private fun base58CheckEncode(payload: ByteArray): String { val checksum = sha256(sha256(payload)).copyOf(4) - val source = payload + checksum - var number = BigInteger(1, source) - val output = StringBuilder() - val base = BigInteger.valueOf(58) - while (number > BigInteger.ZERO) { - val division = number.divideAndRemainder(base) - output.append(BASE58_ALPHABET[division[1].toInt()]) - number = division[0] - } - repeat(source.takeWhile { it == 0.toByte() }.size) { output.append('1') } - return output.reverse().toString() + return Base58.encode(payload + checksum) } private fun sha256(value: ByteArray) = MessageDigest.getInstance("SHA-256").digest(value) - - companion object { - private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - } } 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..aa20c2ca24 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -0,0 +1,240 @@ +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.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.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.WatchOnlyAccountData +import to.bitkit.data.WatchOnlyAccountStore +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.services.LightningService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class WatchOnlyAccountRepoTest : BaseUnitTest() { + @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 = WatchOnlyAccountRepo(testDispatcher, 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 `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 = WatchOnlyAccountRepo(testDispatcher, 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 `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 = WatchOnlyAccountRepo(testDispatcher, 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 = WatchOnlyAccountRepo(testDispatcher, 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 = WatchOnlyAccountRepo(testDispatcher, 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 account() = WatchOnlyAccountRecord( + id = "account-id", + walletIndex = 0, + accountIndex = 1, + addressType = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + xpub = "xpub", + requestFingerprint = "request", + createdAt = 1, + name = "Creator account", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Active, + ) + + private companion object { + 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 1ea0b6530f..e6a68e384a 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -2,16 +2,27 @@ 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.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.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 @@ -59,4 +70,81 @@ 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) + + sut.reconcileWatchOnlyAccounts(listOf(account)) + + 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.load()).thenReturn(listOf(account)) + 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() + } + + 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 606259d764..af84d2054f 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,11 +1,14 @@ package to.bitkit.ui.screens.profile import android.content.Context +import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test 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 @@ -18,6 +21,7 @@ import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) @@ -34,21 +38,62 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `confirmAuthorize reparses capabilities when load has not completed`() = 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(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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(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(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities)), + Result.failure(IllegalArgumentException("request changed")), ) - 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) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } } @Test @@ -79,12 +124,12 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `watch-only authorization delivers signed claim before approving session`() = 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(148), + payload = ByteArray(84), ) whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") whenever( @@ -94,9 +139,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) - whenever { watchOnlyAccountRepo.prepareSignedClaim(authUrl, "paykit server") }.thenReturn(prepared) - whenever { watchOnlyAccountRepo.deliver(prepared, authUrl) }.thenReturn(Unit) - whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() @@ -106,12 +150,194 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.Success, sut.uiState.value.state) - verifyBlocking(watchOnlyAccountRepo) { prepareSignedClaim(authUrl, "paykit server") } - verifyBlocking(watchOnlyAccountRepo) { deliver(prepared, 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(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + 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.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 `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(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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(AppError(authorizationError))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + 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(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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), + ) + whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + 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 `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(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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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.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 `activation persistence failure does not report success or unload the authorized account`() = test { + val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" + val prepared = PreparedWatchOnlyAccountClaim( + account = watchOnlyAccount(), + payload = ByteArray(84), + ) + 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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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 { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { watchOnlyAccountRepo.markActive(prepared.account.id) } + .thenThrow(IllegalStateException("Persistence failed")) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } + verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + } + private fun createSut() = PubkyAuthApprovalViewModel( context = context, pubkyRepo = pubkyRepo, @@ -140,7 +366,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { requestFingerprint = "request", createdAt = 1, name = "paykit server", - isTrackingEnabled = true, + 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 96f65b0d07..32950f8642 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -125,7 +125,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() - inOrder.verify(watchOnlyAccountStore).save(emptyList()) + inOrder.verify(watchOnlyAccountStore).clear() inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/watch-only-account-claim.added.md index 960bfa65c0..6f68318587 100644 --- a/changelog.d/next/watch-only-account-claim.added.md +++ b/changelog.d/next/watch-only-account-claim.added.md @@ -1 +1 @@ -Bitkit now creates, names, signs, backs up, and manages separate watch-only Bitcoin accounts for approved Paykit server setup requests. +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 index 7eb4f0f2f4..03a7508e84 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -6,12 +6,13 @@ This document records the client contract implemented by Bitkit iOS and Android - The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. - The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Retrying the same auth URL reuses its pending account. +- 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. - The user assigns a local name to the account. The name is not disclosed in the claim. -## Signed claim +## Claim payload -The decrypted claim is 148 bytes: +Bitkit serializes this exact 84-byte unsigned payload: | Offset | Size | Value | | --- | ---: | --- | @@ -19,7 +20,8 @@ The decrypted claim is 148 bytes: | 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 | -| 84 | 64 | Ed25519 signature | + +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: @@ -35,14 +37,13 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - 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))}`. -- Bitkit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using the existing XSalsa20-Poly1305 format. -- Bitkit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- 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 retries the same pending claim idempotently. -- Disabling tracking rebuilds LDK Node without registering that account. It does not delete the xpub or revoke the server session. -- Account metadata is included in the existing encrypted wallet backup and uses the same JSON field names on iOS and Android. - -## Required shared protocol work - -The current Paykit app bindings do not expose the companion encrypted-relay operation, so both clients deliberately fail closed at delivery. Paykit must provide one shared binding that derives the domain-separated channel, encrypts, and posts the signed claim; duplicating Pubky relay cryptography in each app is not accepted. - -The server protocol must also communicate its highest issued external address index. Bitkit can then call LDK Node's account-specific reveal API before syncing. Without that high-water mark, addresses beyond the wallet lookahead cannot be guaranteed to appear, even though initial addresses work normally. +- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Success marks it active and leaves tracking enabled; 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. Retrying the same request can then finish normal authorization without losing 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. A v1 Paykit Server must not issue an index above `999`; supporting a higher index requires a future protocol signal that communicates the server's address high-water mark. +- Startup reconciliation restores runtime tracking and the pre-revealed range. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- 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 4425d19900..52c47c5606 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,8 @@ 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" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc35" } +bitcoinj-core = { module = "org.bitcoinj:bitcoinj-core", version = "0.17" } 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 +65,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.54" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.55" } 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" } From 0f1e15f5720a67fbaf6a8a08be7cbba74fbb4b80 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 19:13:46 +0200 Subject: [PATCH 03/13] fix: harden paykit account restore --- .../to/bitkit/data/WatchOnlyAccountStore.kt | 270 +++++++++- .../java/to/bitkit/models/WatchOnlyAccount.kt | 2 + .../java/to/bitkit/repositories/BackupRepo.kt | 5 +- .../to/bitkit/repositories/LightningRepo.kt | 6 +- .../repositories/WatchOnlyAccountRepo.kt | 134 +++-- .../to/bitkit/services/LightningService.kt | 80 +-- .../WatchOnlyAccountLifecycleCoordinator.kt | 13 + .../profile/PubkyAuthApprovalViewModel.kt | 156 ++++-- .../bitkit/ui/utils/PubkyAuthErrorMessage.kt | 1 + .../to/bitkit/usecases/WipeWalletUseCase.kt | 6 +- .../bitkit/data/WatchOnlyAccountStoreTest.kt | 447 +++++++++++++++- .../WatchOnlyAccountDataSerializerTest.kt | 16 + .../to/bitkit/repositories/BackupRepoTest.kt | 16 +- .../bitkit/repositories/LightningRepoTest.kt | 20 + .../repositories/WatchOnlyAccountRepoTest.kt | 503 +++++++++++++++++- .../WatchOnlyAccountRestoreTest.kt | 115 ++++ .../bitkit/services/LightningServiceTest.kt | 79 ++- .../profile/PubkyAuthApprovalViewModelTest.kt | 152 ++++++ .../bitkit/usecases/WipeWalletUseCaseTest.kt | 10 +- docs/watch-only-account-claim-v1.md | 8 +- gradle/libs.versions.toml | 4 +- 21 files changed, 1875 insertions(+), 168 deletions(-) create mode 100644 app/src/main/java/to/bitkit/services/WatchOnlyAccountLifecycleCoordinator.kt create mode 100644 app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt index faf752cec9..3cda215fd1 100644 --- a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -9,8 +9,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import org.bitcoinj.base.Base58 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 @@ -34,6 +37,15 @@ class WatchOnlyAccountStore @Inject constructor( 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( @@ -84,6 +96,11 @@ class WatchOnlyAccountStore @Inject constructor( 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 -> @@ -96,10 +113,16 @@ class WatchOnlyAccountStore @Inject constructor( @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(), @@ -146,8 +169,10 @@ internal fun WatchOnlyAccountData.completeAccountIndexAllocation(requestKey: Str copy(pendingAccountIndexByRequest = pendingAccountIndexByRequest - requestKey) internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccountData { - val account = accounts.firstOrNull { it.id == id } ?: return this - val requestKey = "${account.walletIndex}:${account.requestFingerprint}" + 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) @@ -165,20 +190,251 @@ internal fun WatchOnlyAccountData.restoreAccounts( accounts: List, allocationState: WatchOnlyAccountAllocationState? = null, ): WatchOnlyAccountData { + val locallyManagedAccounts = this.accounts + accountsPendingRemoval + val authorizingAccounts = uniqueLogicalAccounts( + prioritizedAccounts = emptyList(), + remainingAccounts = locallyManagedAccounts.filter { + it.setupState == WatchOnlyAccountSetupState.Authorizing + }, + ) + val authorizingIds = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::id) + val authorizingKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) + val authorizingRequestKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::allocationRequestKey) + val protectedRestorationConflicts = uniqueLogicalAccounts( + prioritizedAccounts = emptyList(), + remainingAccounts = locallyManagedAccounts.filter { account -> + account.setupState != WatchOnlyAccountSetupState.Authorizing && + account.id !in authorizingIds && + account.managementKey() !in authorizingKeys && + (account.setupState == WatchOnlyAccountSetupState.Active || + account.allocationRequestKey() !in authorizingRequestKeys) && + account.shouldProtectFrom(accounts) + }.map { it.promotedTrackingState(accounts) }, + ) + val protectedLocalAccounts = authorizingAccounts + protectedRestorationConflicts + val mergedAccounts = uniqueLogicalAccounts( + prioritizedAccounts = protectedLocalAccounts, + remainingAccounts = accounts, + ) + val restoredKeys = mergedAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) + val updatedAccountsPendingRemoval = uniqueAccountsByManagementKey( + (accountsPendingRemoval + this.accounts).filterNot { it.managementKey() in restoredKeys }, + ) + val localHighestIndexes = highestAccountIndexByWallet + .withAccountIndexes(this.accounts + accountsPendingRemoval) + .withPendingAccountIndexes(pendingAccountIndexByRequest) val restoredHighestIndexes = allocationState?.highestAccountIndexByWallet.orEmpty() val mergedHighestIndexes = restoredHighestIndexes.entries - .fold(highestAccountIndexByWallet) { indexes, (wallet, index) -> + .fold(localHighestIndexes) { indexes, (wallet, index) -> indexes + (wallet to maxOf(indexes[wallet] ?: 0, index)) - }.withAccountIndexes(accounts) - val mergedPendingAllocations = allocationState?.pendingAccountIndexByRequest.orEmpty() + }.withPendingAccountIndexes(allocationState?.pendingAccountIndexByRequest.orEmpty()) + .withAccountIndexes(accounts + mergedAccounts + updatedAccountsPendingRemoval) + val restoredPendingAllocations = allocationState?.pendingAccountIndexByRequest.orEmpty() + .withoutAccountIndexConflicts( + accounts = mergedAccounts, + restoredAccounts = accounts, + accountsPendingRemoval = updatedAccountsPendingRemoval, + localHighestIndexes = localHighestIndexes, + localPendingAccountIndexes = pendingAccountIndexByRequest, + ) + val authorizingAllocations = authorizingAccounts.associate { + it.allocationRequestKey() to it.accountIndex + } + val mergedPendingAllocations = restoredPendingAllocations + authorizingAllocations + val finalizedHighestIndexes = mergedHighestIndexes.withPendingAccountIndexes(mergedPendingAllocations) return copy( - accounts = accounts.sortedBy(WatchOnlyAccountRecord::accountIndex), - highestAccountIndexByWallet = mergedHighestIndexes, + accounts = mergedAccounts.sortedBy(WatchOnlyAccountRecord::accountIndex), + accountsPendingRemoval = updatedAccountsPendingRemoval, + highestAccountIndexByWallet = finalizedHighestIndexes, pendingAccountIndexByRequest = mergedPendingAllocations, ) } +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(): Boolean = walletIndex >= 0 && + accountIndex > 0 && + addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE && + runCatching { Base58.decodeChecked(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH } + .getOrDefault(false) + +private fun WatchOnlyAccountRecord.shouldProtectFrom(restoredAccounts: List): Boolean { + val conflicts = restoredAccounts.filter(WatchOnlyAccountRecord::isUsableAccount).filter { + it.managementKey() == managementKey() || it.id == id + } + val highestRestoredPriority = conflicts.minOfOrNull { it.setupState.restorePriority() } ?: return false + val localPriority = setupState.restorePriority() + if (localPriority != highestRestoredPriority) return localPriority < highestRestoredPriority + return conflicts.any { + it.setupState.restorePriority() == highestRestoredPriority && !hasSameOwner(it) + } +} + +private fun WatchOnlyAccountRecord.promotedTrackingState( + restoredAccounts: List, +): WatchOnlyAccountRecord { + if (setupState != WatchOnlyAccountSetupState.Active) return this + val restoredOwnerRequiresTracking = restoredAccounts.filter(WatchOnlyAccountRecord::isUsableAccount).any { + it.managementKey() == managementKey() && + (it.setupState == WatchOnlyAccountSetupState.Authorizing || + it.setupState == WatchOnlyAccountSetupState.Active && it.isTrackingEnabled) + } + return if (restoredOwnerRequiresTracking) copy(isTrackingEnabled = true) else this +} + +private fun WatchOnlyAccountRecord.hasSameOwner(other: WatchOnlyAccountRecord): Boolean = + managementKey() == other.managementKey() && + requestFingerprint == other.requestFingerprint && + xpub == other.xpub + +private val accountRestoreOrder = compareBy( + { it.setupState.restorePriority() }, + WatchOnlyAccountRecord::createdAt, + WatchOnlyAccountRecord::walletIndex, + WatchOnlyAccountRecord::accountIndex, + WatchOnlyAccountRecord::addressType, + WatchOnlyAccountRecord::requestFingerprint, + WatchOnlyAccountRecord::id, + WatchOnlyAccountRecord::xpub, + WatchOnlyAccountRecord::name, + { !it.isTrackingEnabled }, +) + +private fun WatchOnlyAccountSetupState.restorePriority(): Int = when (this) { + WatchOnlyAccountSetupState.Active -> 0 + WatchOnlyAccountSetupState.Authorizing -> 1 + WatchOnlyAccountSetupState.PendingDelivery -> 2 +} + +private fun uniqueLogicalAccounts( + prioritizedAccounts: List, + remainingAccounts: List, +): List { + val ids = mutableSetOf() + val managementKeys = mutableSetOf() + val incompleteRequestKeys = mutableSetOf() + return buildList { + (prioritizedAccounts + remainingAccounts.sortedWith(accountRestoreOrder)) + .filter(WatchOnlyAccountRecord::isUsableAccount) + .forEach { account -> + val incompleteRequestKey = account + .takeIf { it.setupState != WatchOnlyAccountSetupState.Active } + ?.allocationRequestKey() + if (account.id in ids || account.managementKey() in managementKeys) return@forEach + if (incompleteRequestKey != null && incompleteRequestKey in incompleteRequestKeys) return@forEach + add(account.normalizedTrackingState()) + ids += account.id + managementKeys += account.managementKey() + incompleteRequestKey?.let(incompleteRequestKeys::add) + } + } +} + +private fun uniqueAccountsByManagementKey(accounts: List): List = + accounts.sortedWith(accountRestoreOrder).distinctBy(WatchOnlyAccountRecord::managementKey) + +private data class AccountIndexKey( + val walletIndex: Int, + val accountIndex: Int, +) + +private data class PendingAccountIndexReservation( + val requestKey: String, + val accountIndexKey: AccountIndexKey, +) + +private fun Map.withoutAccountIndexConflicts( + accounts: List, + restoredAccounts: List, + accountsPendingRemoval: List, + localHighestIndexes: Map, + localPendingAccountIndexes: Map, +): Map { + val accountsByIndex = accounts.groupBy { AccountIndexKey(it.walletIndex, it.accountIndex) } + val incompleteAccountIndexesByRequest = accounts + .filter { it.setupState != WatchOnlyAccountSetupState.Active } + .associate { it.allocationRequestKey() to AccountIndexKey(it.walletIndex, it.accountIndex) } + val restoredIncompleteRequestKeys = restoredAccounts.asSequence() + .filter { it.setupState != WatchOnlyAccountSetupState.Active } + .mapTo(mutableSetOf(), WatchOnlyAccountRecord::allocationRequestKey) + val restoredAccountIndexes = restoredAccounts.mapTo(mutableSetOf()) { + AccountIndexKey(it.walletIndex, it.accountIndex) + } + val indexesPendingRemoval = accountsPendingRemoval.mapTo(mutableSetOf()) { + AccountIndexKey(it.walletIndex, it.accountIndex) + } + return entries.asSequence() + .mapNotNull { (requestKey, accountIndex) -> + val walletIndex = requestKey.allocationWalletIndex() ?: return@mapNotNull null + if (accountIndex <= 0) return@mapNotNull null + PendingAccountIndexReservation( + requestKey = requestKey, + accountIndexKey = AccountIndexKey(walletIndex, accountIndex), + ) + } + .filter { reservation -> + if (reservation.accountIndexKey in indexesPendingRemoval) return@filter false + val localAccountIndex = localPendingAccountIndexes[reservation.requestKey] + if (localAccountIndex != null && localAccountIndex != reservation.accountIndexKey.accountIndex) { + return@filter false + } + val incompleteAccountIndex = incompleteAccountIndexesByRequest[reservation.requestKey] + if (reservation.requestKey in restoredIncompleteRequestKeys && incompleteAccountIndex == null) { + return@filter false + } + if (incompleteAccountIndex != null && incompleteAccountIndex != reservation.accountIndexKey) { + return@filter false + } + if (reservation.accountIndexKey in restoredAccountIndexes && incompleteAccountIndex != reservation.accountIndexKey) { + return@filter false + } + val occupyingAccounts = accountsByIndex[reservation.accountIndexKey].orEmpty() + if (occupyingAccounts.isEmpty()) { + val localHighestIndex = localHighestIndexes[reservation.accountIndexKey.walletIndex.toString()] ?: 0 + return@filter localAccountIndex == reservation.accountIndexKey.accountIndex || + reservation.accountIndexKey.accountIndex > localHighestIndex + } + occupyingAccounts.all { + it.setupState != WatchOnlyAccountSetupState.Active && + it.allocationRequestKey() == reservation.requestKey + } + } + .sortedBy(PendingAccountIndexReservation::requestKey) + .distinctBy(PendingAccountIndexReservation::accountIndexKey) + .associate { it.requestKey to it.accountIndexKey.accountIndex } +} + +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 + 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.groupBy(WatchOnlyAccountRecord::walletIndex).forEach { (walletIndex, walletAccounts) -> diff --git a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt index a20f3e6cb8..f632bfc26c 100644 --- a/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt +++ b/app/src/main/java/to/bitkit/models/WatchOnlyAccount.kt @@ -7,6 +7,8 @@ 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 { diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 23c26d05a2..74818a5e5a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -93,6 +93,7 @@ class BackupRepo @Inject constructor( 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, @@ -648,11 +649,11 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) - watchOnlyAccountStore.restore( + watchOnlyAccountRepo.restore( parsed.watchOnlyAccounts.orEmpty(), parsed.watchOnlyAccountAllocationState, ) - lightningService.reconcileWatchOnlyAccounts(parsed.watchOnlyAccounts.orEmpty()) + 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 2c0eb37bc9..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 @@ -342,7 +343,10 @@ class LightningRepo @Inject constructor( if (lightningService.status?.isRunning == true) { Logger.info("LDK node already running", context = TAG) - lightningService.reconcileWatchOnlyAccounts() + 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/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index 121f12cefa..8bdfeb07a2 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -4,22 +4,24 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.bitcoinj.base.Base58 import org.lightningdevkit.ldknode.AddressType import to.bitkit.async.ServiceQueue +import to.bitkit.data.WatchOnlyAccountAllocationState import to.bitkit.data.WatchOnlyAccountStore 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_NATIVE_SEGWIT_ADDRESS_TYPE +import to.bitkit.models.WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH 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.services.LightningService +import to.bitkit.services.WatchOnlyAccountLifecycleCoordinator import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder @@ -33,25 +35,30 @@ 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 mutationMutex = Mutex() - val accounts: Flow> = store.data.map { it.accounts } suspend fun prepareUnsignedClaim(authUrl: String, name: String): PreparedWatchOnlyAccountClaim = withContext(bgDispatcher) { - mutationMutex.withLock { + lifecycleCoordinator.withLock { val normalizedName = normalizeName(name) val walletIndex = lightningService.currentWalletIndex val fingerprint = requestFingerprint(authUrl) @@ -93,58 +100,84 @@ class WatchOnlyAccountRepo @Inject constructor( } } - suspend fun markActive(id: String) { - mutationMutex.withLock { + 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) { - mutationMutex.withLock { + lifecycleCoordinator.withLock { val account = store.load().firstOrNull { it.id == id && it.setupState != WatchOnlyAccountSetupState.Active - } ?: return@withLock - 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 + } ?: 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) } - updateResult.onFailure { - runSuspendCatching { setAccountTracking(account, enabled = false) } - } - updateResult.getOrThrow() + preserveAuthorizingState } } - suspend fun cancelAuthorization(id: String) = withContext(NonCancellable) { - mutationMutex.withLock { + 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 - store.save( - current.map { - if (it.id == id) { - it.copy( - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - } else { - it - } - }, - ) - setAccountTracking(account, enabled = false) + 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() } } @@ -154,7 +187,7 @@ class WatchOnlyAccountRepo @Inject constructor( } suspend fun setTrackingEnabled(id: String, enabled: Boolean) = withContext(NonCancellable) { - mutationMutex.withLock { + lifecycleCoordinator.withLock { val current = store.load() val account = current.firstOrNull { it.id == id } ?: return@withLock if (account.setupState != WatchOnlyAccountSetupState.Active) return@withLock @@ -171,8 +204,19 @@ class WatchOnlyAccountRepo @Inject constructor( } } - suspend fun restore(accounts: List?) { - store.restore(accounts.orEmpty()) + 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 { @@ -224,7 +268,7 @@ class WatchOnlyAccountRepo @Inject constructor( } private suspend fun updateAccount(id: String, transform: (WatchOnlyAccountRecord) -> WatchOnlyAccountRecord) { - mutationMutex.withLock { + lifecycleCoordinator.withLock { store.update { accounts -> accounts.map { if (it.id == id) transform(it) else it } } } } @@ -266,7 +310,7 @@ class WatchOnlyAccountRepo @Inject constructor( } companion object { - const val ADDRESS_TYPE_NATIVE_SEGWIT = "nativeSegwit" + const val ADDRESS_TYPE_NATIVE_SEGWIT = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE private const val MAX_NAME_LENGTH = 64 } } @@ -282,7 +326,7 @@ private fun decodeQueryComponent(value: String): String = object WatchOnlyAccountClaimCodec { const val VERSION: Byte = 1 const val NATIVE_SEGWIT_ADDRESS_TYPE: Byte = 0 - const val SERIALIZED_XPUB_LENGTH = 78 + 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): ByteArray { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index a8af988a88..455aab741f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -57,6 +57,7 @@ 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 @@ -90,7 +91,7 @@ internal fun enabledOnchainWalletAccountConfigs( .filter { it.isTrackingEnabled } .map { record -> val addressType = when (record.addressType) { - "nativeSegwit" -> LdkAddressType.NATIVE_SEGWIT + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> LdkAddressType.NATIVE_SEGWIT else -> throw IllegalArgumentException("Unsupported watch-only account address type") } OnchainWalletAccountConfig( @@ -108,7 +109,7 @@ data class AddressDerivationInfo( val index: Int, ) -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @Singleton class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -117,6 +118,7 @@ class LightningService @Inject constructor( private val settingsStore: SettingsStore, private val watchOnlyAccountStore: WatchOnlyAccountStore, private val loggerLdk: LoggerLdk, + private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, ) : BaseCoroutineScope(bgDispatcher) { companion object { @@ -365,47 +367,53 @@ class LightningService @Inject constructor( } } - suspend fun reconcileWatchOnlyAccounts( - records: List? = null, - syncAfterReconcile: Boolean = true, - ) { - val node = node ?: return - val walletRecords = (records ?: watchOnlyAccountStore.load()).filter { it.walletIndex == currentWalletIndex } - val desiredConfigs = enabledOnchainWalletAccountConfigs(walletRecords, currentWalletIndex) + 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.mapNotNull { record -> - when (record.addressType) { - "nativeSegwit" -> accountKey(LdkAddressType.NATIVE_SEGWIT, record.accountIndex.toUInt()) - else -> null - } - }.toSet() - val desiredKeys = desiredConfigs.map { accountKey(it.addressType, it.accountIndex) }.toSet() + ServiceQueue.LDK.background { + val trackedAccounts = node.listOnchainWalletAccounts() + val managedKeys = (walletRecords + accountsPendingRemoval).mapNotNull { record -> + when (record.addressType) { + "nativeSegwit" -> 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) + 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 + 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 (!isTracked) { - node.addOnchainWalletAccount(config.addressType, config.accountIndex, config.xpub) + + if (syncAfterReconcile && desiredConfigs.isNotEmpty() && node.status().isRunning) { + node.syncWallets() } - 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) } } } 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/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index a3f68d5aae..f49a3627d0 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 @@ -20,13 +20,17 @@ 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 @@ -45,8 +49,18 @@ class PubkyAuthApprovalViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private val inFlightAuthorization = AtomicReference() + fun load(authUrl: String) { - _uiState.value = PubkyAuthApprovalUiState(authUrl = authUrl) + 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 request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { if (_uiState.value.authUrl != authUrl) return@launch @@ -96,69 +110,116 @@ class PubkyAuthApprovalViewModel @Inject constructor( } fun confirmAuthorize(authUrl: String) { - if (!transitionToAuthorizing(authUrl)) return + 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 { - val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { - handleApprovalFailure(it, authUrl) - return@launch + try { + authorize(authUrl, authorizingState.watchOnlyAccountName) + } finally { + inFlightAuthorization.compareAndSet(authorization, null) } - if (_uiState.value.authUrl != authUrl) return@launch + } + } - val preparedClaim = runSuspendCatching { - if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { - watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, _uiState.value.watchOnlyAccountName) - } else { - null - } - }.getOrElse { - handleApprovalFailure(it, authUrl) - return@launch - } + private suspend fun authorize(authUrl: String, watchOnlyAccountName: String) { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + handleApprovalFailure(it, authUrl) + return + } + if (_uiState.value.authUrl != authUrl) return + if (!approveRequest(request, authUrl, watchOnlyAccountName)) return - preparedClaim?.let { claim -> - runSuspendCatching { watchOnlyAccountRepo.beginAuthorization(claim.account.id) }.getOrElse { - cancelIncompleteSetup(claim.account.id) - handleApprovalFailure(it, authUrl) - return@launch - } + 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, + watchOnlyAccountName: String, + ): Boolean { + val preparedClaim = runSuspendCatching { + if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, watchOnlyAccountName) + } else { + null } + }.getOrElse { + handleApprovalFailure(it, authUrl) + return false + } - val approvalResult = preparedClaim?.let { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) - } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) - if (approvalResult.isFailure) { - val approvalError = approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed") - preparedClaim?.let { claim -> - if (!approvalError.isPostDeliveryAuthorizationFailure()) { - cancelIncompleteSetup(claim.account.id) + 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 } - handleApprovalFailure(approvalError, authUrl) - return@launch - } + } + val approvalResult = preparedClaim?.let { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) + } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) + if (approvalResult.isFailure) { + val approvalError = approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed") preparedClaim?.let { claim -> - runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { - handleApprovalFailure(it, authUrl) - return@launch + if (!approvalError.isPostDeliveryAuthorizationFailure()) { + cancelIncompleteSetup( + claim.account.id, + preserveAuthorizingState, + ) } } - 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 + 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): Boolean { + private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { val initialState = _uiState.value - if (initialState.authUrl != authUrl || initialState.state != ApprovalState.Authorize) return false - return _uiState.compareAndSet(initialState, initialState.copy(state = ApprovalState.Authorizing)) + if (initialState.authUrl != authUrl || initialState.state != ApprovalState.Authorize) return null + val authorizingState = initialState.copy(state = ApprovalState.Authorizing) + return authorizingState.takeIf { _uiState.compareAndSet(initialState, it) } } - private suspend fun cancelIncompleteSetup(accountId: String) { - runSuspendCatching { watchOnlyAccountRepo.cancelAuthorization(accountId) } + private fun resetForLoad(authUrl: String): Boolean { + while (true) { + val currentState = _uiState.value + if (currentState.authUrl == authUrl && currentState.state == 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", @@ -208,6 +269,13 @@ sealed interface 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) { diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt index 79108e356c..f683c6335d 100644 --- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -14,6 +14,7 @@ fun Throwable.localizedPubkyAuthMessage(context: Context): String? { 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 diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 1ca8fb2c2b..ce9e32170e 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -4,7 +4,6 @@ import com.google.firebase.messaging.FirebaseMessaging 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.keychain.Keychain import to.bitkit.ext.runSuspendCatching @@ -16,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 @@ -32,7 +32,7 @@ class WipeWalletUseCase @Inject constructor( private val db: AppDb, private val settingsStore: SettingsStore, private val cacheStore: CacheStore, - private val watchOnlyAccountStore: WatchOnlyAccountStore, + private val watchOnlyAccountRepo: WatchOnlyAccountRepo, private val widgetsStore: WidgetsStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, @@ -68,7 +68,7 @@ class WipeWalletUseCase @Inject constructor( settingsStore.reset() cacheStore.reset() - watchOnlyAccountStore.clear() + watchOnlyAccountRepo.clear() widgetsStore.reset() blocktankRepo.resetState() diff --git a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt index 0fe5528caf..70f59e70ed 100644 --- a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -3,13 +3,21 @@ package to.bitkit.data import org.junit.Test import to.bitkit.di.json import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE 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() @@ -70,6 +78,233 @@ class WatchOnlyAccountStoreTest { assertEquals(allocationState.pendingAccountIndexByRequest, restored.pendingAccountIndexByRequest) } + @Test + fun `restored pending reservation raises an inconsistent allocator high water mark`() { + val restored = WatchOnlyAccountData().restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 2), + pendingAccountIndexByRequest = mapOf("0:pending" to 9), + ), + ) + + assertEquals(9, restored.highestAccountIndexByWallet["0"]) + assertEquals(9, restored.reserveAccountIndex(0, "pending").accountIndex) + assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex) + } + + @Test + fun `restore drops an unbound reservation below the local high water mark`() { + val restored = WatchOnlyAccountData( + highestAccountIndexByWallet = mapOf("0" to 9), + ).restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:stale" to 7), + ), + ) + + assertFalse("0:stale" in restored.pendingAccountIndexByRequest) + assertEquals(9, restored.highestAccountIndexByWallet["0"]) + assertEquals(10, restored.reserveAccountIndex(0, "stale").accountIndex) + } + + @Test + fun `restore keeps only an exact local pending reservation below the high water mark`() { + val local = WatchOnlyAccountData( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ) + val exact = local.restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:pending" to 7), + ), + ) + val divergent = local.restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 0), + pendingAccountIndexByRequest = mapOf("0:pending" to 8), + ), + ) + + assertEquals(7, exact.pendingAccountIndexByRequest["0:pending"]) + assertEquals(7, exact.reserveAccountIndex(0, "pending").accountIndex) + assertEquals(8, exact.reserveAccountIndex(0, "next").accountIndex) + assertFalse("0:pending" in divergent.pendingAccountIndexByRequest) + assertEquals(9, divergent.reserveAccountIndex(0, "pending").accountIndex) + } + + @Test + fun `restore deterministically keeps one request per free account index`() { + val restored = WatchOnlyAccountData().restoreAccounts( + accounts = emptyList(), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 2), + pendingAccountIndexByRequest = linkedMapOf( + "0:z-request" to 7, + "0:a-request" to 7, + ), + ), + ) + + assertEquals(mapOf("0:a-request" to 7), restored.pendingAccountIndexByRequest) + assertEquals(7, restored.highestAccountIndexByWallet["0"]) + assertEquals(7, restored.reserveAccountIndex(0, "a-request").accountIndex) + assertEquals(8, restored.reserveAccountIndex(0, "z-request").accountIndex) + } + + @Test + fun `restore keeps one deterministic owner for a duplicate id`() { + val completed = account(accountIndex = 7).copy(id = "duplicate-id", createdAt = 2) + val incomplete = account(accountIndex = 3).copy( + id = completed.id, + createdAt = 1, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + val forward = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(incomplete, completed), + allocationState = WatchOnlyAccountAllocationState( + pendingAccountIndexByRequest = mapOf( + "0:${incomplete.requestFingerprint}" to incomplete.accountIndex, + ), + ), + ) + val reversed = WatchOnlyAccountData().restoreAccounts(listOf(completed, incomplete)) + + assertEquals(listOf(completed), forward.accounts) + assertEquals(forward.accounts, reversed.accounts) + assertEquals(7, forward.highestAccountIndexByWallet["0"]) + assertFalse("0:${incomplete.requestFingerprint}" in forward.pendingAccountIndexByRequest) + } + + @Test + fun `restore rejects a reservation at a discarded account index`() { + val retained = account(accountIndex = 1).copy(id = "duplicate-id") + val discarded = account(accountIndex = 7).copy(id = retained.id) + val restored = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(discarded, retained), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 7), + pendingAccountIndexByRequest = mapOf("0:retry" to 7), + ), + ) + + assertFalse("0:retry" in restored.pendingAccountIndexByRequest) + assertEquals(8, restored.reserveAccountIndex(0, "retry").accountIndex) + } + + @Test + fun `restore normalizes tracking for incomplete accounts`() { + val pending = account(accountIndex = 1).copy( + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val authorizing = account(accountIndex = 2).copy( + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val disabledActive = account(accountIndex = 3).copy(isTrackingEnabled = false) + + val restored = WatchOnlyAccountData().restoreAccounts(listOf(pending, authorizing, disabledActive)) + + assertFalse(restored.accounts.first { it.id == pending.id }.isTrackingEnabled) + assertTrue(restored.accounts.first { it.id == authorizing.id }.isTrackingEnabled) + assertFalse(restored.accounts.first { it.id == disabledActive.id }.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", + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val accountZero = account(accountIndex = 0) + + val restored = WatchOnlyAccountData().restoreAccounts( + listOf(invalidXpub, accountZero, invalidAddressType, valid), + ) + + assertEquals(listOf(valid), restored.accounts) + assertEquals(9, restored.reserveAccountIndex(0, "next").accountIndex) + } + + @Test + fun `restore keeps one deterministic owner for a duplicate management key`() { + val completed = account(accountIndex = 5).copy(id = "completed", createdAt = 2) + val incomplete = completed.copy( + id = "incomplete", + xpub = "other-xpub", + requestFingerprint = "other-request", + createdAt = 1, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + val forward = WatchOnlyAccountData().restoreAccounts(listOf(incomplete, completed)) + val reversed = WatchOnlyAccountData().restoreAccounts(listOf(completed, incomplete)) + + assertEquals(listOf(completed), forward.accounts) + assertEquals(forward.accounts, reversed.accounts) + } + + @Test + fun `restore keeps one deterministic owner for an incomplete request`() { + val first = account(accountIndex = 4).copy( + id = "first", + requestFingerprint = "shared-request", + createdAt = 1, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val later = account(accountIndex = 3).copy( + id = "later", + requestFingerprint = first.requestFingerprint, + createdAt = 2, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 4), + pendingAccountIndexByRequest = mapOf("0:${first.requestFingerprint}" to first.accountIndex), + ) + + val forward = WatchOnlyAccountData().restoreAccounts(listOf(later, first), allocationState) + val reversed = WatchOnlyAccountData().restoreAccounts(listOf(first, later), allocationState) + + assertEquals(listOf(first), forward.accounts) + assertEquals(forward.accounts, reversed.accounts) + assertEquals(first.accountIndex, forward.pendingAccountIndexByRequest["0:${first.requestFingerprint}"]) + assertEquals(4, forward.highestAccountIndexByWallet["0"]) + } + + @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)) + .restoreAccounts(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( @@ -112,13 +347,213 @@ class WatchOnlyAccountStoreTest { assertTrue(requestKey in data.pendingAccountIndexByRequest) } - private fun account(accountIndex: Int) = WatchOnlyAccountRecord( - id = "account-$accountIndex", - walletIndex = 0, + @Test + fun `restore preserves and activates authorizing accounts over backup conflicts`() { + val authorizingById = account(accountIndex = 4).copy( + id = "authorizing-by-id", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val authorizingByIdentity = account(accountIndex = 5).copy( + id = "authorizing-by-identity", + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val conflictById = account(accountIndex = 6).copy(id = authorizingById.id) + val conflictByIdentity = authorizingByIdentity.copy( + id = "restored-conflict", + requestFingerprint = "restored-conflict", + setupState = WatchOnlyAccountSetupState.Active, + ) + val restoredAccount = account(accountIndex = 7) + val restoredPendingKey = "0:${restoredAccount.requestFingerprint}" + val data = WatchOnlyAccountData( + accounts = listOf(authorizingById, authorizingByIdentity), + pendingAccountIndexByRequest = mapOf( + "0:${authorizingById.requestFingerprint}" to authorizingById.accountIndex, + "0:${authorizingByIdentity.requestFingerprint}" to authorizingByIdentity.accountIndex, + ), + ) + + val restored = data.restoreAccounts( + accounts = listOf(conflictById, conflictByIdentity, restoredAccount), + allocationState = WatchOnlyAccountAllocationState( + pendingAccountIndexByRequest = mapOf(restoredPendingKey to restoredAccount.accountIndex), + ), + ) + val activated = restored.markAccountActive(authorizingById.id) + + assertEquals( + setOf(authorizingById.id, authorizingByIdentity.id, restoredAccount.id), + restored.accounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::id), + ) + assertEquals( + WatchOnlyAccountSetupState.Active, + activated.accounts.first { it.id == authorizingById.id }.setupState, + ) + assertTrue(activated.accounts.first { it.id == authorizingById.id }.isTrackingEnabled) + assertEquals(authorizingByIdentity, restored.accounts.first { it.id == authorizingByIdentity.id }) + assertEquals( + authorizingById.accountIndex, + restored.pendingAccountIndexByRequest["0:${authorizingById.requestFingerprint}"], + ) + assertEquals( + authorizingByIdentity.accountIndex, + restored.pendingAccountIndexByRequest["0:${authorizingByIdentity.requestFingerprint}"], + ) + assertFalse(restoredPendingKey in restored.pendingAccountIndexByRequest) + assertTrue(restored.accountsPendingRemoval.isEmpty()) + } + + @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)).restoreAccounts(listOf(conflictingBackup)) + + assertEquals(listOf(local), restored.accounts) + assertTrue(restored.accountsPendingRemoval.isEmpty()) + } + + @Test + fun `restore prefers a delivered owner over a local pending slot`() { + val localPending = account(accountIndex = 5).copy( + requestFingerprint = "local-pending", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val restoredActive = account(accountIndex = 5).copy( + id = "restored-active", + requestFingerprint = "restored-active", + ) + + val restored = WatchOnlyAccountData(accounts = listOf(localPending)).restoreAccounts(listOf(restoredActive)) + + assertEquals(listOf(restoredActive), restored.accounts) + assertTrue(restored.accounts.single().isTrackingEnabled) + assertTrue(restored.accountsPendingRemoval.isEmpty()) + } + + @Test + fun `restore keeps tracking enabled across delivered owner conflicts`() { + val localDisabled = account(accountIndex = 5).copy( + requestFingerprint = "local-active", + isTrackingEnabled = false, + ) + val restoredEnabled = account(accountIndex = 5).copy( + id = "restored-active", + requestFingerprint = "restored-active", + ) + + val restored = WatchOnlyAccountData(accounts = listOf(localDisabled)).restoreAccounts(listOf(restoredEnabled)) + + assertEquals(localDisabled.id, restored.accounts.single().id) + assertTrue(restored.accounts.single().isTrackingEnabled) + } + + @Test + fun `restore protects an owner awaiting removal from a conflicting slot`() { + val local = account(accountIndex = 5) + val conflictingBackup = local.copy( + id = "restored-owner", + requestFingerprint = "restored-request", + ) + val awaitingRemoval = WatchOnlyAccountData(accounts = listOf(local)).restoreAccounts(emptyList()) + + val restored = awaitingRemoval.restoreAccounts(listOf(conflictingBackup)) + + assertEquals(listOf(local), restored.accounts) + assertTrue(restored.accountsPendingRemoval.isEmpty()) + } + + @Test + fun `restore does not regress an active local owner to incomplete`() { + val local = account(accountIndex = 5) + val incompleteBackup = local.copy( + id = "incomplete-backup", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreAccounts(listOf(incompleteBackup)) + + assertEquals(listOf(local), restored.accounts) + } + + @Test + fun `restore uses the same canonical incomplete owner regardless of input order`() { + val lowerIndex = account(accountIndex = 3).copy( + id = "z-lower-index", + requestFingerprint = "shared-request", + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val higherIndex = account(accountIndex = 4).copy( + id = "a-higher-index", + requestFingerprint = lowerIndex.requestFingerprint, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + + val forward = WatchOnlyAccountData().restoreAccounts(listOf(higherIndex, lowerIndex)) + val reversed = WatchOnlyAccountData().restoreAccounts(listOf(lowerIndex, higherIndex)) + + assertEquals(listOf(lowerIndex), forward.accounts) + assertEquals(forward.accounts, reversed.accounts) + } + + @Test + fun `current authorizing account wins a duplicate incomplete request`() { + val authorizing = account(accountIndex = 8).copy( + id = "authorizing", + requestFingerprint = "shared-request", + createdAt = 2, + isTrackingEnabled = true, + setupState = WatchOnlyAccountSetupState.Authorizing, + ) + val restoredDuplicate = account(accountIndex = 1).copy( + id = "restored", + requestFingerprint = authorizing.requestFingerprint, + createdAt = 1, + isTrackingEnabled = false, + setupState = WatchOnlyAccountSetupState.PendingDelivery, + ) + val requestKey = "0:${authorizing.requestFingerprint}" + val restored = WatchOnlyAccountData( + accounts = listOf(authorizing), + pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex), + ).restoreAccounts( + accounts = listOf(restoredDuplicate), + allocationState = WatchOnlyAccountAllocationState( + pendingAccountIndexByRequest = mapOf(requestKey to restoredDuplicate.accountIndex), + ), + ) + + assertEquals(listOf(authorizing), restored.accounts) + assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey]) + 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 = "nativeSegwit", - xpub = "xpub", - requestFingerprint = "request-$accountIndex", + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, + xpub = TEST_XPUB, + requestFingerprint = "request-$walletIndex-$accountIndex", createdAt = 1, name = "Account $accountIndex", isTrackingEnabled = true, diff --git a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt index 91ff5b6658..be965d7435 100644 --- a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt +++ b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt @@ -4,8 +4,24 @@ import androidx.datastore.core.CorruptionException import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertIs +import kotlin.test.assertTrue class WatchOnlyAccountDataSerializerTest { + @Test + fun `existing data defaults pending removals to empty`() = runTest { + val existingData = """ + { + "accounts": [], + "highestAccountIndexByWallet": {}, + "pendingAccountIndexByRequest": {} + } + """.trimIndent() + + val decoded = WatchOnlyAccountDataSerializer.readFrom(existingData.byteInputStream()) + + assertTrue(decoded.accountsPendingRemoval.isEmpty()) + } + @Test fun `malformed JSON is reported as datastore corruption`() = runTest { val error = runCatching { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 2e28fe0ccc..7a389f388f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -63,6 +63,7 @@ class BackupRepoTest : BaseUnitTest() { 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() @@ -299,21 +300,21 @@ class BackupRepoTest : BaseUnitTest() { watchOnlyAccounts = listOf(account), watchOnlyAccountAllocationState = allocationState, ) - var storeWasRestored = false - whenever { watchOnlyAccountStore.restore(listOf(account), allocationState) }.thenAnswer { - storeWasRestored = true + var accountsWereRestored = false + whenever { watchOnlyAccountRepo.restore(listOf(account), allocationState) }.thenAnswer { + accountsWereRestored = true Unit } - whenever { lightningService.reconcileWatchOnlyAccounts(listOf(account)) }.thenAnswer { - assertTrue(storeWasRestored) + whenever { lightningService.reconcileWatchOnlyAccounts() }.thenAnswer { + assertTrue(accountsWereRestored) Unit } val result = sut.performFullRestoreFromLatestBackup() assertTrue(result.isSuccess) - verifyBlocking(watchOnlyAccountStore) { restore(listOf(account), allocationState) } - verifyBlocking(lightningService) { reconcileWatchOnlyAccounts(listOf(account)) } + verifyBlocking(watchOnlyAccountRepo) { restore(listOf(account), allocationState) } + verifyBlocking(lightningService) { reconcileWatchOnlyAccounts() } } @Test @@ -394,6 +395,7 @@ class BackupRepoTest : BaseUnitTest() { settingsStore = settingsStore, widgetsStore = widgetsStore, watchOnlyAccountStore = watchOnlyAccountStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, blocktankRepo = blocktankRepo, activityRepo = activityRepo, pubkyRepo = pubkyRepo, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 30da57aeda..33ecceb425 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -208,6 +208,26 @@ class LightningRepoTest : BaseUnitTest() { 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/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt index aa20c2ca24..3d5d03a675 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -1,6 +1,10 @@ package to.bitkit.repositories +import kotlinx.coroutines.CompletableDeferred +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 @@ -8,24 +12,119 @@ 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.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.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +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.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.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 `authorization fails before tracking when the prepared account is missing`() = test { + val store = mock() + val lightningService = mock() + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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 = WatchOnlyAccountRepo( + testDispatcher, + 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() @@ -41,7 +140,12 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { whenever(store.reserveAccountIndex(any(), any())).thenReturn(1) whenever(lightningService.node).thenReturn(node) whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u)).thenReturn(TEST_XPUB) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService) + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) val first = sut.prepareUnsignedClaim( "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=same&" + @@ -64,6 +168,128 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { 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), + ), + ) + 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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), + ), + ) + 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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) @@ -88,7 +314,12 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { node ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService) + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) sut.beginAuthorization(account.id) sut.cancelAuthorization(account.id) @@ -103,6 +334,121 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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 = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) + + 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) @@ -123,7 +469,12 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) ) whenever(node.onchainPayment()).thenReturn(onchainPayment) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService) + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) sut.beginAuthorization(account.id) @@ -160,7 +511,12 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { doAnswer { isTracked = false }.whenever(node) .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) whenever(node.syncWallets()).thenThrow(syncError) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService) + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) val error = runCatching { sut.beginAuthorization(account.id) }.exceptionOrNull() @@ -200,7 +556,12 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { doAnswer { isTracked = true }.whenever( node ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService) + val sut = WatchOnlyAccountRepo( + testDispatcher, + store, + lightningService, + WatchOnlyAccountLifecycleCoordinator(), + ) sut.setTrackingEnabled(account.id, enabled = false) @@ -219,6 +580,131 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { verify(node, times(1)).syncWallets() } + @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 = WatchOnlyAccountRepo(testDispatcher, 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 = WatchOnlyAccountRepo(testDispatcher, 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 account() = WatchOnlyAccountRecord( id = "account-id", walletIndex = 0, @@ -233,8 +719,15 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { ) 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..5093b8bfc4 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt @@ -0,0 +1,115 @@ +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.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.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 one canonical incomplete account after restore`() = test { + val canonicalAccount = account(id = "canonical-account", accountIndex = 5, createdAt = 1) + val duplicateAccount = account(id = "duplicate-account", accountIndex = 6, createdAt = 2) + val requestKey = "0:$REQUEST_FINGERPRINT" + val storedData = WatchOnlyAccountData().restoreAccounts( + accounts = listOf(duplicateAccount, canonicalAccount), + allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet = mapOf("0" to 6), + pendingAccountIndexByRequest = mapOf(requestKey to canonicalAccount.accountIndex), + ), + ) + 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 sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator) + + val prepared = sut.prepareUnsignedClaim(AUTH_URL, canonicalAccount.name) + lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) + + assertEquals(listOf(canonicalAccount), storedData.accounts) + assertEquals(canonicalAccount.id, prepared.account.id) + assertEquals(canonicalAccount.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(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 6u, TEST_XPUB) + verify(onchainPayment).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 5u, + WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), + ) + verify(onchainPayment, never()).revealReceiveAddressesToAccount( + AddressType.NATIVE_SEGWIT, + 6u, + 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 = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + 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 e6a68e384a..66c7feea66 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -7,12 +7,14 @@ 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 @@ -33,6 +35,7 @@ class LightningServiceTest : BaseUnitTest() { private val watchOnlyAccountStore = mock() private val loggerLdk = mock() private val node = mock() + private val watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator() private lateinit var sut: LightningService @@ -45,6 +48,7 @@ class LightningServiceTest : BaseUnitTest() { settingsStore = settingsStore, watchOnlyAccountStore = watchOnlyAccountStore, loggerLdk = loggerLdk, + watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ) sut.node = node } @@ -103,7 +107,11 @@ class LightningServiceTest : BaseUnitTest() { whenever(node.status()).thenReturn(status) whenever(status.isRunning).thenReturn(true) - sut.reconcileWatchOnlyAccounts(listOf(account)) + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) + + sut.reconcileWatchOnlyAccounts() verify(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u, account.xpub) verify(onchainPayment).revealReceiveAddressesToAccount( @@ -119,7 +127,9 @@ class LightningServiceTest : BaseUnitTest() { val account = watchOnlyAccount(accountIndex = 5, walletIndex = 0, enabled = true) .copy(setupState = WatchOnlyAccountSetupState.Authorizing) val onchainPayment = mock() - whenever(watchOnlyAccountStore.load()).thenReturn(listOf(account)) + whenever(watchOnlyAccountStore.loadReconciliationState()).thenReturn( + WatchOnlyAccountReconciliationState(listOf(account), emptyList()), + ) whenever(node.listOnchainWalletAccounts()).thenReturn( listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 5u)) ) @@ -135,6 +145,71 @@ class LightningServiceTest : BaseUnitTest() { 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, 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 af84d2054f..bed7d4c28a 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 @@ -2,10 +2,13 @@ package to.bitkit.ui.screens.profile import android.content.Context 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.Test +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -19,6 +22,7 @@ import to.bitkit.models.PubkyAuthRequest 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 @@ -140,6 +144,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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() @@ -175,6 +180,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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() @@ -192,6 +198,67 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever( + context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") + ).thenReturn("paykit server") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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.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}" @@ -213,6 +280,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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() @@ -246,6 +314,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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() @@ -262,6 +331,45 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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(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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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.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}" @@ -300,6 +408,49 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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(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") + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + 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.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 `activation persistence failure does not report success or unload the authorized account`() = test { val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" @@ -323,6 +474,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ), ) 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) } .thenThrow(IllegalStateException("Persistence failed")) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 32950f8642..54ab73af9b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -13,7 +13,6 @@ import org.mockito.kotlin.whenever 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.keychain.Keychain import to.bitkit.repositories.ActivityRepo @@ -24,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 @@ -39,7 +39,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val db = mock() private val settingsStore = mock() private val cacheStore = mock() - private val watchOnlyAccountStore = mock() + private val watchOnlyAccountRepo = mock() private val widgetsStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() @@ -74,7 +74,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db = db, settingsStore = settingsStore, cacheStore = cacheStore, - watchOnlyAccountStore = watchOnlyAccountStore, + watchOnlyAccountRepo = watchOnlyAccountRepo, widgetsStore = widgetsStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, @@ -103,7 +103,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { db, settingsStore, cacheStore, - watchOnlyAccountStore, + watchOnlyAccountRepo, widgetsStore, blocktankRepo, activityRepo, @@ -125,7 +125,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() inOrder.verify(cacheStore).reset() - inOrder.verify(watchOnlyAccountStore).clear() + inOrder.verify(watchOnlyAccountRepo).clear() inOrder.verify(widgetsStore).reset() inOrder.verify(blocktankRepo).resetState() inOrder.verify(activityRepo).resetState() diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index 03a7508e84..f857a101a0 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -27,10 +27,12 @@ The signature input is the byte concatenation: ```text UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(UTF8(decoded_auth_request_secret)) +|| 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 @@ -44,6 +46,6 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. Retrying the same request can then finish normal authorization without losing 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. A v1 Paykit Server must not issue an index above `999`; supporting a higher index requires a future protocol signal that communicates the server's address high-water mark. -- Startup reconciliation restores runtime tracking and the pre-revealed range. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- 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. +- Startup reconciliation restores runtime tracking and the pre-revealed range. Reconciliation is serialized with setup, restore, and tracking mutations so a stale snapshot cannot undo the latest account state. When a backup replaces the account list, references to removed accounts remain locally until reconciliation successfully unloads them. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. - 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 52c47c5606..7b41bcb773 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ 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-rc35" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc36" } bitcoinj-core = { module = "org.bitcoinj:bitcoinj-core", version = "0.17" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } @@ -65,7 +65,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.55" } +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" } From 14442af40086d8116ec67145c533c03de6ddce43 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 19:17:57 +0200 Subject: [PATCH 04/13] chore: rename changelog fragment --- .../next/{watch-only-account-claim.added.md => 1084.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{watch-only-account-claim.added.md => 1084.added.md} (100%) diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/1084.added.md similarity index 100% rename from changelog.d/next/watch-only-account-claim.added.md rename to changelog.d/next/1084.added.md From c6aac16944930761bca85ffe60ebeeb8e4ec4737 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 21:47:17 +0200 Subject: [PATCH 05/13] fix: recover completed account activation --- .../to/bitkit/data/WatchOnlyAccountStore.kt | 50 ++++++++++++------- .../repositories/WatchOnlyAccountRepo.kt | 2 +- .../profile/PubkyAuthApprovalViewModel.kt | 20 ++++++++ .../bitkit/data/WatchOnlyAccountStoreTest.kt | 2 +- .../repositories/WatchOnlyAccountRepoTest.kt | 1 + .../profile/PubkyAuthApprovalViewModelTest.kt | 12 ++++- 6 files changed, 65 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt index 3cda215fd1..bb784a57a4 100644 --- a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -191,12 +191,7 @@ internal fun WatchOnlyAccountData.restoreAccounts( allocationState: WatchOnlyAccountAllocationState? = null, ): WatchOnlyAccountData { val locallyManagedAccounts = this.accounts + accountsPendingRemoval - val authorizingAccounts = uniqueLogicalAccounts( - prioritizedAccounts = emptyList(), - remainingAccounts = locallyManagedAccounts.filter { - it.setupState == WatchOnlyAccountSetupState.Authorizing - }, - ) + val authorizingAccounts = locallyManagedAccounts.uniqueAuthorizingAccounts() val authorizingIds = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::id) val authorizingKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) val authorizingRequestKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::allocationRequestKey) @@ -206,8 +201,10 @@ internal fun WatchOnlyAccountData.restoreAccounts( account.setupState != WatchOnlyAccountSetupState.Authorizing && account.id !in authorizingIds && account.managementKey() !in authorizingKeys && - (account.setupState == WatchOnlyAccountSetupState.Active || - account.allocationRequestKey() !in authorizingRequestKeys) && + ( + account.setupState == WatchOnlyAccountSetupState.Active || + account.allocationRequestKey() !in authorizingRequestKeys + ) && account.shouldProtectFrom(accounts) }.map { it.promotedTrackingState(accounts) }, ) @@ -255,6 +252,12 @@ private fun WatchOnlyAccountRecord.managementKey(): String = "$walletIndex:$addr private fun WatchOnlyAccountRecord.allocationRequestKey(): String = "$walletIndex:$requestFingerprint" +private fun List.uniqueAuthorizingAccounts(): List = + uniqueLogicalAccounts( + prioritizedAccounts = emptyList(), + remainingAccounts = filter { it.setupState == WatchOnlyAccountSetupState.Authorizing }, + ) + private fun WatchOnlyAccountRecord.normalizedTrackingState(): WatchOnlyAccountRecord = when (setupState) { WatchOnlyAccountSetupState.PendingDelivery -> copy(isTrackingEnabled = false) WatchOnlyAccountSetupState.Authorizing -> copy(isTrackingEnabled = true) @@ -285,8 +288,10 @@ private fun WatchOnlyAccountRecord.promotedTrackingState( if (setupState != WatchOnlyAccountSetupState.Active) return this val restoredOwnerRequiresTracking = restoredAccounts.filter(WatchOnlyAccountRecord::isUsableAccount).any { it.managementKey() == managementKey() && - (it.setupState == WatchOnlyAccountSetupState.Authorizing || - it.setupState == WatchOnlyAccountSetupState.Active && it.isTrackingEnabled) + ( + it.setupState == WatchOnlyAccountSetupState.Authorizing || + it.setupState == WatchOnlyAccountSetupState.Active && it.isTrackingEnabled + ) } return if (restoredOwnerRequiresTracking) copy(isTrackingEnabled = true) else this } @@ -373,14 +378,7 @@ private fun Map.withoutAccountIndexConflicts( AccountIndexKey(it.walletIndex, it.accountIndex) } return entries.asSequence() - .mapNotNull { (requestKey, accountIndex) -> - val walletIndex = requestKey.allocationWalletIndex() ?: return@mapNotNull null - if (accountIndex <= 0) return@mapNotNull null - PendingAccountIndexReservation( - requestKey = requestKey, - accountIndexKey = AccountIndexKey(walletIndex, accountIndex), - ) - } + .mapNotNull { (requestKey, accountIndex) -> pendingReservation(requestKey, accountIndex) } .filter { reservation -> if (reservation.accountIndexKey in indexesPendingRemoval) return@filter false val localAccountIndex = localPendingAccountIndexes[reservation.requestKey] @@ -394,7 +392,7 @@ private fun Map.withoutAccountIndexConflicts( if (incompleteAccountIndex != null && incompleteAccountIndex != reservation.accountIndexKey) { return@filter false } - if (reservation.accountIndexKey in restoredAccountIndexes && incompleteAccountIndex != reservation.accountIndexKey) { + if (reservation.conflictsWithRestoredAccount(restoredAccountIndexes, incompleteAccountIndex)) { return@filter false } val occupyingAccounts = accountsByIndex[reservation.accountIndexKey].orEmpty() @@ -413,6 +411,20 @@ private fun Map.withoutAccountIndexConflicts( .associate { it.requestKey to it.accountIndexKey.accountIndex } } +private fun pendingReservation(requestKey: String, accountIndex: Int): PendingAccountIndexReservation? { + val walletIndex = requestKey.allocationWalletIndex() ?: return null + if (accountIndex <= 0) return null + return PendingAccountIndexReservation( + requestKey = requestKey, + accountIndexKey = AccountIndexKey(walletIndex, accountIndex), + ) +} + +private fun PendingAccountIndexReservation.conflictsWithRestoredAccount( + restoredAccountIndexes: Set, + incompleteAccountIndex: AccountIndexKey?, +): Boolean = accountIndexKey in restoredAccountIndexes && incompleteAccountIndex != accountIndexKey + private fun String.allocationWalletIndex(): Int? { val separatorIndex = indexOf(':') if (separatorIndex <= 0 || separatorIndex == lastIndex) return null diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index 8bdfeb07a2..cdf37d10a0 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -15,9 +15,9 @@ 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.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.services.LightningService 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 f49a3627d0..ad8be9ad88 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 @@ -50,6 +50,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( val effects = _effects.asSharedFlow() private val inFlightAuthorization = AtomicReference() + private val accountIdsAwaitingLocalActivation = mutableMapOf() fun load(authUrl: String) { inFlightAuthorization.get()?.takeIf { it.authUrl == authUrl }?.let { authorization -> @@ -146,6 +147,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( request: PubkyAuthRequest, authUrl: String, watchOnlyAccountName: String, + ): Boolean = resumeLocalActivation(authUrl) ?: approveNewRequest(request, authUrl, watchOnlyAccountName) + + private suspend fun approveNewRequest( + request: PubkyAuthRequest, + authUrl: String, + watchOnlyAccountName: String, ): Boolean { val preparedClaim = runSuspendCatching { if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { @@ -190,14 +197,27 @@ class PubkyAuthApprovalViewModel @Inject constructor( } preparedClaim?.let { claim -> + accountIdsAwaitingLocalActivation[authUrl] = claim.account.id runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { handleApprovalFailure(it, authUrl) return false } + accountIdsAwaitingLocalActivation.remove(authUrl, claim.account.id) } return true } + private suspend fun resumeLocalActivation(authUrl: String): Boolean? { + val accountId = accountIdsAwaitingLocalActivation[authUrl] ?: return null + val result = runSuspendCatching { watchOnlyAccountRepo.markActive(accountId) } + if (result.isSuccess) { + accountIdsAwaitingLocalActivation.remove(authUrl, accountId) + return true + } + handleApprovalFailure(result.exceptionOrNull() ?: IllegalStateException("Account activation failed"), authUrl) + return false + } + private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { val initialState = _uiState.value if (initialState.authUrl != authUrl || initialState.state != ApprovalState.Authorize) return null diff --git a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt index 70f59e70ed..9eaec51b7a 100644 --- a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -2,8 +2,8 @@ package to.bitkit.data import org.junit.Test import to.bitkit.di.json -import to.bitkit.models.WalletBackupV1 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 diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt index 3d5d03a675..b77124cca9 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -46,6 +46,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class WatchOnlyAccountRepoTest : BaseUnitTest() { @Test fun `authorization fails before tracking when the prepared account is missing`() = test { 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 bed7d4c28a..3eda99b3dc 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 @@ -452,7 +452,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `activation persistence failure does not report success or unload the authorized account`() = test { + fun `activation persistence retry completes locally without repeating authorization`() = test { val authUrl = "pubkyauth://signin?secret=request&caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" val prepared = PreparedWatchOnlyAccountClaim( account = watchOnlyAccount(), @@ -478,6 +478,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) whenever { watchOnlyAccountRepo.markActive(prepared.account.id) } .thenThrow(IllegalStateException("Persistence failed")) + .thenReturn(Unit) val sut = createSut() sut.load(authUrl) @@ -488,6 +489,15 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Authorize, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } + + 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(2)) { markActive(prepared.account.id) } } private fun createSut() = PubkyAuthApprovalViewModel( From 4a867422b1ab30ca4caf60527d532a164a9dcd57 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:06:13 +0200 Subject: [PATCH 06/13] feat: stage Paykit server authorization --- .../screens/profile/PubkyAuthApprovalSheet.kt | 349 +++++++++++------- .../profile/PubkyAuthApprovalViewModel.kt | 65 +++- app/src/main/res/values/strings.xml | 4 + .../profile/PubkyAuthApprovalViewModelTest.kt | 47 ++- 4 files changed, 318 insertions(+), 147 deletions(-) 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 98074cfdcd..5198897d4f 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,9 +11,8 @@ 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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -24,9 +23,11 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,16 +41,18 @@ 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 -import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.settingsViewModel @@ -58,6 +61,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 @@ -67,6 +71,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) } @@ -77,8 +110,6 @@ fun PubkyAuthApprovalSheet( val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() val isBiometrySupported = rememberBiometricAuthSupported() - LaunchedEffect(authUrl) { viewModel.load(authUrl) } - LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { @@ -112,47 +143,36 @@ fun PubkyAuthApprovalSheet( } } - Box { - Content( - uiState = uiState, - isCurrentRequest = uiState.authUrl == authUrl, - onAuthorize = { - if (uiState.authUrl == authUrl) viewModel.requestAuthorize(authUrl) + 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 }, - onAccountNameChange = viewModel::updateWatchOnlyAccountName, - 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 = 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 + }, + ) } } @@ -177,15 +197,26 @@ private fun Content( uiState: PubkyAuthApprovalUiState, isCurrentRequest: Boolean, onAuthorize: () -> Unit, - onAccountNameChange: (String) -> Unit, + onApproveWatchOnly: () -> Unit, + onBackToWatchOnly: () -> Unit, onCancel: () -> Unit, onDismiss: () -> Unit, ) { val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading - val headerTitle = if (approvalState == ApprovalState.Success) { - stringResource(R.string.profile__auth_approval_success) - } else { - stringResource(R.string.profile__auth_approval_title) + val headerTitle = 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) + } + val onBack = when (approvalState) { + ApprovalState.Authorize -> if (uiState.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + onBackToWatchOnly + } else { + onCancel + } + ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel + ApprovalState.Success -> onDismiss + else -> null } Column( @@ -195,17 +226,20 @@ private fun Content( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - SheetTopBar(titleText = headerTitle) + SheetTopBar(titleText = headerTitle, onBack = onBack) when (approvalState) { ApprovalState.Loading -> LoadingContent() + ApprovalState.WatchOnlyConsent -> WatchOnlyConsentContent( + onApprove = onApproveWatchOnly, + onCancel = onCancel, + ) ApprovalState.Authorize -> AuthorizeContent( uiState = uiState, onAuthorize = onAuthorize, - onAccountNameChange = onAccountNameChange, onCancel = onCancel, ) - ApprovalState.Authorizing -> AuthorizingContent( + ApprovalState.Authenticating, ApprovalState.Authorizing -> AuthorizingContent( uiState = uiState, ) ApprovalState.Success -> SuccessContent( @@ -216,6 +250,59 @@ private fun Content( } } +@Composable +private fun ColumnScope.WatchOnlyConsentContent( + onApprove: () -> Unit, + onCancel: () -> Unit, +) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .testTag("PubkyAuthWatchOnlyConsent") + ) { + 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, + ) + + FillHeight(min = 12.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() @@ -232,14 +319,9 @@ private fun ColumnScope.LoadingContent() { private fun ColumnScope.AuthorizeContent( uiState: PubkyAuthApprovalUiState, onAuthorize: () -> Unit, - onAccountNameChange: (String) -> Unit, onCancel: () -> Unit, ) { - ApprovalDetails( - uiState = uiState, - accountNameEnabled = true, - onAccountNameChange = onAccountNameChange, - ) + ApprovalDetails(uiState = uiState) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( @@ -260,17 +342,15 @@ private fun ColumnScope.AuthorizeContent( private fun ColumnScope.AuthorizingContent( uiState: PubkyAuthApprovalUiState, ) { - ApprovalDetails( - uiState = uiState, - accountNameEnabled = false, - onAccountNameChange = {}, - ) + ApprovalDetails(uiState = uiState) - PrimaryButton( + BodyMSB( text = stringResource(R.string.profile__auth_approval_authorizing), - onClick = {}, - isLoading = true, - enabled = false, + color = Colors.White32, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp), ) VerticalSpacer(16.dp) } @@ -278,30 +358,13 @@ private fun ColumnScope.AuthorizingContent( @Composable private fun ColumnScope.ApprovalDetails( uiState: PubkyAuthApprovalUiState, - accountNameEnabled: Boolean, - onAccountNameChange: (String) -> Unit, ) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { + Column(modifier = Modifier.weight(1f)) { DescriptionText(serviceName = uiState.serviceName) VerticalSpacer(32.dp) PermissionsSection(permissions = uiState.permissions) - VerticalSpacer(16.dp) - - uiState.bitkitClaim?.let { - BitkitClaimSection(it) - VerticalSpacer(16.dp) - WatchOnlyAccountName( - value = uiState.watchOnlyAccountName, - enabled = accountNameEnabled, - onValueChange = onAccountNameChange, - ) - VerticalSpacer(16.dp) - } + FillHeight(min = 32.dp) TrustWarning() VerticalSpacer(16.dp) @@ -318,7 +381,7 @@ private fun ColumnScope.SuccessContent( ) { SuccessDescriptionText( serviceName = uiState.serviceName, - truncatedKey = uiState.profile?.truncatedPublicKey ?: "", + truncatedKey = uiState.profile?.authDisplayPublicKey.orEmpty(), ) VerticalSpacer(16.dp) @@ -400,51 +463,6 @@ private fun PermissionRow(permission: PubkyAuthPermission) { } } -@Composable -private fun BitkitClaimSection(bitkitClaim: PubkyAuthClaim) { - when (bitkitClaim) { - PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1 -> Column( - modifier = Modifier - .fillMaxWidth() - .background(Colors.Gray6, RoundedCornerShape(16.dp)) - .padding(16.dp), - ) { - Text13Up( - text = stringResource(R.string.profile__auth_approval_watch_only_account_title), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - BodyM( - text = stringResource(R.string.profile__auth_approval_watch_only_account_description), - color = Colors.White64, - ) - } - } -} - -@Composable -private fun WatchOnlyAccountName( - value: String, - enabled: Boolean, - onValueChange: (String) -> Unit, -) { - Text13Up( - text = stringResource(R.string.profile__auth_approval_watch_only_account_name), - color = Colors.White64, - ) - VerticalSpacer(8.dp) - TextInput( - value = value, - onValueChange = onValueChange, - enabled = enabled, - placeholder = stringResource(R.string.profile__auth_approval_watch_only_account_name_placeholder), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .testTag("PubkyAuthWatchOnlyAccountName"), - ) -} - @Composable private fun TrustWarning() { BodyM( @@ -462,12 +480,63 @@ 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)) + } +} + +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 = {}, + ) + } } } @@ -496,7 +565,8 @@ private fun AuthorizePreview() { ), isCurrentRequest = true, onAuthorize = {}, - onAccountNameChange = {}, + onApproveWatchOnly = {}, + onBackToWatchOnly = {}, onCancel = {}, onDismiss = {}, ) @@ -516,7 +586,8 @@ private fun SuccessPreview() { ), isCurrentRequest = true, onAuthorize = {}, - onAccountNameChange = {}, + 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 ad8be9ad88..8a04efc2e7 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 @@ -77,7 +77,13 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (_uiState.value.authUrl != authUrl) return@launch val unknownService = context.getString(R.string.profile__auth_approval_service_unknown) val serviceName = request.serviceNames.firstOrNull() ?: unknownService - val profile = pubkyRepo.profile.value + val profile = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey -> + PubkyProfile.forDisplay( + publicKey = publicKey, + name = pubkyRepo.displayName.value, + imageUrl = pubkyRepo.displayImageUri.value, + ) + } val accountName = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) } else { @@ -86,7 +92,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( _uiState.update { it.copy( - state = ApprovalState.Authorize, + state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { + ApprovalState.WatchOnlyConsent + } else { + ApprovalState.Authorize + }, serviceName = serviceName, requestedCapabilities = request.capabilities, permissions = request.permissions.toImmutableList(), @@ -101,13 +111,44 @@ 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 updateWatchOnlyAccountName(name: String) { - _uiState.update { it.copy(watchOnlyAccountName = name) } + 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) { @@ -220,7 +261,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { val initialState = _uiState.value - if (initialState.authUrl != authUrl || initialState.state != ApprovalState.Authorize) return null + 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) } } @@ -228,7 +274,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( private fun resetForLoad(authUrl: String): Boolean { while (true) { val currentState = _uiState.value - if (currentState.authUrl == authUrl && currentState.state == ApprovalState.Authorizing) return false + if ( + currentState.authUrl == authUrl && + currentState.state in setOf(ApprovalState.Authenticating, ApprovalState.Authorizing) + ) { + return false + } if (_uiState.compareAndSet(currentState, PubkyAuthApprovalUiState(authUrl = authUrl))) return true } } @@ -279,7 +330,9 @@ data class PubkyAuthApprovalUiState( 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 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6318c3055..a41b35f953 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -579,6 +579,10 @@ ACCOUNT NAME Name this account Watch-only Bitcoin account + 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. 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 3eda99b3dc..50a4d84d8d 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import org.junit.Test +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -19,6 +20,7 @@ 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 @@ -31,7 +33,16 @@ 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() @Test @@ -41,6 +52,12 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Loading, sut.uiState.value.state) } + @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" @@ -122,9 +139,26 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() - assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) assertEquals("paykit server", sut.uiState.value.watchOnlyAccountName) + + 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 @@ -151,6 +185,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -187,6 +222,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -229,6 +265,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) runCurrent() assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) @@ -287,6 +324,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -321,6 +359,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -359,6 +398,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -399,6 +439,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -441,6 +482,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -483,6 +525,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { sut.load(authUrl) advanceUntilIdle() + sut.approveWatchOnlyConsent(authUrl) sut.confirmAuthorize(authUrl) advanceUntilIdle() From 7008776779972c5ccede3052f7ff9c1734a49516 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:14:59 +0200 Subject: [PATCH 07/13] refactor: drop unpublished paykit compatibility --- .../screens/profile/PubkyAuthApprovalSheet.kt | 43 ++++++++++++------- .../WatchOnlyAccountDataSerializerTest.kt | 16 ------- 2 files changed, 28 insertions(+), 31 deletions(-) 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 5198897d4f..ddb8d12362 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 @@ -203,21 +203,14 @@ private fun Content( onDismiss: () -> Unit, ) { val approvalState = if (isCurrentRequest) uiState.state else ApprovalState.Loading - val headerTitle = 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) - } - val onBack = when (approvalState) { - ApprovalState.Authorize -> if (uiState.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { - onBackToWatchOnly - } else { - onCancel - } - ApprovalState.Authenticating, ApprovalState.Authorizing -> onCancel - ApprovalState.Success -> onDismiss - else -> null - } + val headerTitle = approvalHeaderTitle(approvalState) + val onBack = approvalBackAction( + approvalState = approvalState, + bitkitClaim = uiState.bitkitClaim, + onBackToWatchOnly = onBackToWatchOnly, + onCancel = onCancel, + onDismiss = onDismiss, + ) Column( modifier = Modifier @@ -250,6 +243,26 @@ 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, diff --git a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt index be965d7435..91ff5b6658 100644 --- a/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt +++ b/app/src/test/java/to/bitkit/data/serializers/WatchOnlyAccountDataSerializerTest.kt @@ -4,24 +4,8 @@ import androidx.datastore.core.CorruptionException import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertIs -import kotlin.test.assertTrue class WatchOnlyAccountDataSerializerTest { - @Test - fun `existing data defaults pending removals to empty`() = runTest { - val existingData = """ - { - "accounts": [], - "highestAccountIndexByWallet": {}, - "pendingAccountIndexByRequest": {} - } - """.trimIndent() - - val decoded = WatchOnlyAccountDataSerializer.readFrom(existingData.byteInputStream()) - - assertTrue(decoded.accountsPendingRemoval.isEmpty()) - } - @Test fun `malformed JSON is reported as datastore corruption`() = runTest { val error = runCatching { From 2fd3149d8982d59d5b3b80301a84edefa786e2df Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:47:28 +0200 Subject: [PATCH 08/13] refactor: simplify watch-only account lifecycle --- .../to/bitkit/data/WatchOnlyAccountStore.kt | 321 ++++++-------- .../bitkit/data/WatchOnlyAccountStoreTest.kt | 391 ++---------------- .../WatchOnlyAccountRestoreTest.kt | 25 +- docs/watch-only-account-claim-v1.md | 2 +- 4 files changed, 168 insertions(+), 571 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt index bb784a57a4..65a298dae2 100644 --- a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -165,9 +165,6 @@ internal fun WatchOnlyAccountData.reserveAccountIndex( ) } -internal fun WatchOnlyAccountData.completeAccountIndexAllocation(requestKey: String): WatchOnlyAccountData = - copy(pendingAccountIndexByRequest = pendingAccountIndexByRequest - requestKey) - internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccountData { val account = checkNotNull(accounts.firstOrNull { it.id == id }) { "Watch-only account '$id' not found" @@ -190,61 +187,51 @@ internal fun WatchOnlyAccountData.restoreAccounts( accounts: List, allocationState: WatchOnlyAccountAllocationState? = null, ): WatchOnlyAccountData { - val locallyManagedAccounts = this.accounts + accountsPendingRemoval - val authorizingAccounts = locallyManagedAccounts.uniqueAuthorizingAccounts() - val authorizingIds = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::id) - val authorizingKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) - val authorizingRequestKeys = authorizingAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::allocationRequestKey) - val protectedRestorationConflicts = uniqueLogicalAccounts( - prioritizedAccounts = emptyList(), - remainingAccounts = locallyManagedAccounts.filter { account -> - account.setupState != WatchOnlyAccountSetupState.Authorizing && - account.id !in authorizingIds && - account.managementKey() !in authorizingKeys && - ( - account.setupState == WatchOnlyAccountSetupState.Active || - account.allocationRequestKey() !in authorizingRequestKeys - ) && - account.shouldProtectFrom(accounts) - }.map { it.promotedTrackingState(accounts) }, - ) - val protectedLocalAccounts = authorizingAccounts + protectedRestorationConflicts - val mergedAccounts = uniqueLogicalAccounts( - prioritizedAccounts = protectedLocalAccounts, - remainingAccounts = accounts, - ) - val restoredKeys = mergedAccounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::managementKey) - val updatedAccountsPendingRemoval = uniqueAccountsByManagementKey( - (accountsPendingRemoval + this.accounts).filterNot { it.managementKey() in restoredKeys }, - ) + val restoredAccounts = accounts.sanitizedAccounts() + val locallyManagedAccounts = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval) + val protectedLocalAccounts = locallyManagedAccounts + .filter { localAccount -> + localAccount.setupState == WatchOnlyAccountSetupState.Authorizing || + localAccount.shouldPreserveFrom(restoredAccounts) + } + .sanitizedAccounts() + val mergedAccounts = (protectedLocalAccounts + restoredAccounts).sanitizedAccounts() + 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 - .withAccountIndexes(this.accounts + accountsPendingRemoval) - .withPendingAccountIndexes(pendingAccountIndexByRequest) - val restoredHighestIndexes = allocationState?.highestAccountIndexByWallet.orEmpty() - val mergedHighestIndexes = restoredHighestIndexes.entries + .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)) - }.withPendingAccountIndexes(allocationState?.pendingAccountIndexByRequest.orEmpty()) - .withAccountIndexes(accounts + mergedAccounts + updatedAccountsPendingRemoval) - val restoredPendingAllocations = allocationState?.pendingAccountIndexByRequest.orEmpty() - .withoutAccountIndexConflicts( - accounts = mergedAccounts, - restoredAccounts = accounts, - accountsPendingRemoval = updatedAccountsPendingRemoval, - localHighestIndexes = localHighestIndexes, - localPendingAccountIndexes = pendingAccountIndexByRequest, - ) - val authorizingAllocations = authorizingAccounts.associate { - it.allocationRequestKey() to it.accountIndex + } + .withAccountIndexes(locallyManagedAccounts + accounts + updatedAccountsPendingRemoval) + + val retainedLocalPendingAccountIndexes = if (allocationState == null) { + emptyMap() + } else { + validLocalPendingAccountIndexes } - val mergedPendingAllocations = restoredPendingAllocations + authorizingAllocations - val finalizedHighestIndexes = mergedHighestIndexes.withPendingAccountIndexes(mergedPendingAllocations) + val mergedPendingAccountIndexes = mergedPendingAccountIndexes( + accounts = mergedAccounts, + blockedAccounts = updatedAccountsPendingRemoval, + localPendingAccountIndexes = retainedLocalPendingAccountIndexes, + restoredPendingAccountIndexes = allocationState?.pendingAccountIndexByRequest.orEmpty(), + localHighestAccountIndexByWallet = localHighestIndexes, + ) return copy( - accounts = mergedAccounts.sortedBy(WatchOnlyAccountRecord::accountIndex), + accounts = mergedAccounts, accountsPendingRemoval = updatedAccountsPendingRemoval, - highestAccountIndexByWallet = finalizedHighestIndexes, - pendingAccountIndexByRequest = mergedPendingAllocations, + highestAccountIndexByWallet = highestIndexes.withPendingAccountIndexes(mergedPendingAccountIndexes), + pendingAccountIndexByRequest = mergedPendingAccountIndexes, ) } @@ -252,12 +239,6 @@ private fun WatchOnlyAccountRecord.managementKey(): String = "$walletIndex:$addr private fun WatchOnlyAccountRecord.allocationRequestKey(): String = "$walletIndex:$requestFingerprint" -private fun List.uniqueAuthorizingAccounts(): List = - uniqueLogicalAccounts( - prioritizedAccounts = emptyList(), - remainingAccounts = filter { it.setupState == WatchOnlyAccountSetupState.Authorizing }, - ) - private fun WatchOnlyAccountRecord.normalizedTrackingState(): WatchOnlyAccountRecord = when (setupState) { WatchOnlyAccountSetupState.PendingDelivery -> copy(isTrackingEnabled = false) WatchOnlyAccountSetupState.Authorizing -> copy(isTrackingEnabled = true) @@ -270,30 +251,46 @@ private fun WatchOnlyAccountRecord.isUsableAccount(): Boolean = walletIndex >= 0 runCatching { Base58.decodeChecked(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH } .getOrDefault(false) -private fun WatchOnlyAccountRecord.shouldProtectFrom(restoredAccounts: List): Boolean { - val conflicts = restoredAccounts.filter(WatchOnlyAccountRecord::isUsableAccount).filter { - it.managementKey() == managementKey() || it.id == id - } - val highestRestoredPriority = conflicts.minOfOrNull { it.setupState.restorePriority() } ?: return false - val localPriority = setupState.restorePriority() - if (localPriority != highestRestoredPriority) return localPriority < highestRestoredPriority - return conflicts.any { - it.setupState.restorePriority() == highestRestoredPriority && !hasSameOwner(it) - } +private fun List.sanitizedAccounts(): List { + val ids = mutableSetOf() + val managementKeys = mutableSetOf() + val incompleteRequestKeys = mutableSetOf() + + return mapNotNull { input -> + if (!input.isUsableAccount()) 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 WatchOnlyAccountRecord.promotedTrackingState( +private fun uniqueAccountsByManagementKey(accounts: List): List = + accounts.distinctBy(WatchOnlyAccountRecord::managementKey) + .sortedWith(compareBy(WatchOnlyAccountRecord::walletIndex, WatchOnlyAccountRecord::accountIndex)) + +private fun WatchOnlyAccountRecord.shouldPreserveFrom( restoredAccounts: List, -): WatchOnlyAccountRecord { - if (setupState != WatchOnlyAccountSetupState.Active) return this - val restoredOwnerRequiresTracking = restoredAccounts.filter(WatchOnlyAccountRecord::isUsableAccount).any { - it.managementKey() == managementKey() && - ( - it.setupState == WatchOnlyAccountSetupState.Authorizing || - it.setupState == WatchOnlyAccountSetupState.Active && it.isTrackingEnabled - ) +): Boolean { + val conflicts = restoredAccounts.filter { + it.id == id || it.managementKey() == managementKey() } - return if (restoredOwnerRequiresTracking) copy(isTrackingEnabled = true) else this + 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 = @@ -301,129 +298,67 @@ private fun WatchOnlyAccountRecord.hasSameOwner(other: WatchOnlyAccountRecord): requestFingerprint == other.requestFingerprint && xpub == other.xpub -private val accountRestoreOrder = compareBy( - { it.setupState.restorePriority() }, - WatchOnlyAccountRecord::createdAt, - WatchOnlyAccountRecord::walletIndex, - WatchOnlyAccountRecord::accountIndex, - WatchOnlyAccountRecord::addressType, - WatchOnlyAccountRecord::requestFingerprint, - WatchOnlyAccountRecord::id, - WatchOnlyAccountRecord::xpub, - WatchOnlyAccountRecord::name, - { !it.isTrackingEnabled }, -) - -private fun WatchOnlyAccountSetupState.restorePriority(): Int = when (this) { - WatchOnlyAccountSetupState.Active -> 0 - WatchOnlyAccountSetupState.Authorizing -> 1 - WatchOnlyAccountSetupState.PendingDelivery -> 2 -} - -private fun uniqueLogicalAccounts( - prioritizedAccounts: List, - remainingAccounts: List, -): List { - val ids = mutableSetOf() - val managementKeys = mutableSetOf() - val incompleteRequestKeys = mutableSetOf() - return buildList { - (prioritizedAccounts + remainingAccounts.sortedWith(accountRestoreOrder)) - .filter(WatchOnlyAccountRecord::isUsableAccount) - .forEach { account -> - val incompleteRequestKey = account - .takeIf { it.setupState != WatchOnlyAccountSetupState.Active } - ?.allocationRequestKey() - if (account.id in ids || account.managementKey() in managementKeys) return@forEach - if (incompleteRequestKey != null && incompleteRequestKey in incompleteRequestKeys) return@forEach - add(account.normalizedTrackingState()) - ids += account.id - managementKeys += account.managementKey() - incompleteRequestKey?.let(incompleteRequestKeys::add) - } - } -} - -private fun uniqueAccountsByManagementKey(accounts: List): List = - accounts.sortedWith(accountRestoreOrder).distinctBy(WatchOnlyAccountRecord::managementKey) - private data class AccountIndexKey( val walletIndex: Int, val accountIndex: Int, ) -private data class PendingAccountIndexReservation( - val requestKey: String, - val accountIndexKey: AccountIndexKey, -) - -private fun Map.withoutAccountIndexConflicts( +private fun mergedPendingAccountIndexes( accounts: List, - restoredAccounts: List, - accountsPendingRemoval: List, - localHighestIndexes: Map, + blockedAccounts: List, localPendingAccountIndexes: Map, + restoredPendingAccountIndexes: Map, + localHighestAccountIndexByWallet: Map, ): Map { - val accountsByIndex = accounts.groupBy { AccountIndexKey(it.walletIndex, it.accountIndex) } - val incompleteAccountIndexesByRequest = accounts - .filter { it.setupState != WatchOnlyAccountSetupState.Active } - .associate { it.allocationRequestKey() to AccountIndexKey(it.walletIndex, it.accountIndex) } - val restoredIncompleteRequestKeys = restoredAccounts.asSequence() - .filter { it.setupState != WatchOnlyAccountSetupState.Active } - .mapTo(mutableSetOf(), WatchOnlyAccountRecord::allocationRequestKey) - val restoredAccountIndexes = restoredAccounts.mapTo(mutableSetOf()) { - AccountIndexKey(it.walletIndex, it.accountIndex) + 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 } - val indexesPendingRemoval = accountsPendingRemoval.mapTo(mutableSetOf()) { - AccountIndexKey(it.walletIndex, it.accountIndex) + + 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 entries.asSequence() - .mapNotNull { (requestKey, accountIndex) -> pendingReservation(requestKey, accountIndex) } - .filter { reservation -> - if (reservation.accountIndexKey in indexesPendingRemoval) return@filter false - val localAccountIndex = localPendingAccountIndexes[reservation.requestKey] - if (localAccountIndex != null && localAccountIndex != reservation.accountIndexKey.accountIndex) { - return@filter false - } - val incompleteAccountIndex = incompleteAccountIndexesByRequest[reservation.requestKey] - if (reservation.requestKey in restoredIncompleteRequestKeys && incompleteAccountIndex == null) { - return@filter false - } - if (incompleteAccountIndex != null && incompleteAccountIndex != reservation.accountIndexKey) { - return@filter false - } - if (reservation.conflictsWithRestoredAccount(restoredAccountIndexes, incompleteAccountIndex)) { - return@filter false - } - val occupyingAccounts = accountsByIndex[reservation.accountIndexKey].orEmpty() - if (occupyingAccounts.isEmpty()) { - val localHighestIndex = localHighestIndexes[reservation.accountIndexKey.walletIndex.toString()] ?: 0 - return@filter localAccountIndex == reservation.accountIndexKey.accountIndex || - reservation.accountIndexKey.accountIndex > localHighestIndex - } - occupyingAccounts.all { - it.setupState != WatchOnlyAccountSetupState.Active && - it.allocationRequestKey() == reservation.requestKey - } - } - .sortedBy(PendingAccountIndexReservation::requestKey) - .distinctBy(PendingAccountIndexReservation::accountIndexKey) - .associate { it.requestKey to it.accountIndexKey.accountIndex } -} -private fun pendingReservation(requestKey: String, accountIndex: Int): PendingAccountIndexReservation? { - val walletIndex = requestKey.allocationWalletIndex() ?: return null - if (accountIndex <= 0) return null - return PendingAccountIndexReservation( - requestKey = requestKey, - accountIndexKey = AccountIndexKey(walletIndex, accountIndex), - ) + return pendingAccountIndexes } -private fun PendingAccountIndexReservation.conflictsWithRestoredAccount( - restoredAccountIndexes: Set, - incompleteAccountIndex: AccountIndexKey?, -): Boolean = accountIndexKey in restoredAccountIndexes && incompleteAccountIndex != accountIndexKey +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(':') @@ -437,6 +372,7 @@ private fun Map.withPendingAccountIndexes( 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) } @@ -449,10 +385,11 @@ internal fun WatchOnlyAccountData.completeReconciliation(walletIndex: Int): Watc private fun Map.withAccountIndexes(accounts: List): Map { val updated = toMutableMap() - accounts.groupBy(WatchOnlyAccountRecord::walletIndex).forEach { (walletIndex, walletAccounts) -> - val highestAccountIndex = walletAccounts.maxOf(WatchOnlyAccountRecord::accountIndex) - val walletKey = walletIndex.toString() - updated[walletKey] = maxOf(updated[walletKey] ?: 0, highestAccountIndex) - } + 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/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt index 9eaec51b7a..a76c848259 100644 --- a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -31,11 +31,7 @@ class WatchOnlyAccountStoreTest { assertEquals(1, reserve("first")) assertEquals(1, reserve("first")) assertEquals(2, reserve("second")) - - data = data.completeAccountIndexAllocation("0:first") - - assertEquals(3, reserve("first")) - assertEquals(3, data.highestAccountIndexByWallet["0"]) + assertEquals(2, data.highestAccountIndexByWallet["0"]) } @Test @@ -48,21 +44,20 @@ class WatchOnlyAccountStoreTest { } @Test - fun `restoring an older backup does not lower the allocator high water mark`() { + 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.restoreAccounts(listOf(account(accountIndex = 7))) - val reservation = restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "next") assertEquals(emptyMap(), restored.pendingAccountIndexByRequest) - assertEquals(11, reservation.accountIndex) + assertEquals(11, restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "pending").accountIndex) } @Test - fun `restoring allocator backup preserves pending reuse and monotonic high water mark`() { + fun `allocator backup restores pending reuse and monotonic high water`() { val allocationState = WatchOnlyAccountAllocationState( highestAccountIndexByWallet = mapOf("0" to 9), pendingAccountIndexByRequest = mapOf("0:pending" to 7), @@ -75,157 +70,40 @@ class WatchOnlyAccountStoreTest { assertEquals(7, restored.reserveAccountIndex(0, "pending").accountIndex) assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex) - assertEquals(allocationState.pendingAccountIndexByRequest, restored.pendingAccountIndexByRequest) - } - - @Test - fun `restored pending reservation raises an inconsistent allocator high water mark`() { - val restored = WatchOnlyAccountData().restoreAccounts( - accounts = emptyList(), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 2), - pendingAccountIndexByRequest = mapOf("0:pending" to 9), - ), - ) - - assertEquals(9, restored.highestAccountIndexByWallet["0"]) - assertEquals(9, restored.reserveAccountIndex(0, "pending").accountIndex) - assertEquals(10, restored.reserveAccountIndex(0, "new").accountIndex) - } - - @Test - fun `restore drops an unbound reservation below the local high water mark`() { - val restored = WatchOnlyAccountData( - highestAccountIndexByWallet = mapOf("0" to 9), - ).restoreAccounts( - accounts = emptyList(), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 7), - pendingAccountIndexByRequest = mapOf("0:stale" to 7), - ), - ) - - assertFalse("0:stale" in restored.pendingAccountIndexByRequest) - assertEquals(9, restored.highestAccountIndexByWallet["0"]) - assertEquals(10, restored.reserveAccountIndex(0, "stale").accountIndex) - } - - @Test - fun `restore keeps only an exact local pending reservation below the high water mark`() { - val local = WatchOnlyAccountData( - highestAccountIndexByWallet = mapOf("0" to 7), - pendingAccountIndexByRequest = mapOf("0:pending" to 7), - ) - val exact = local.restoreAccounts( - accounts = emptyList(), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 7), - pendingAccountIndexByRequest = mapOf("0:pending" to 7), - ), - ) - val divergent = local.restoreAccounts( - accounts = emptyList(), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 0), - pendingAccountIndexByRequest = mapOf("0:pending" to 8), - ), - ) - - assertEquals(7, exact.pendingAccountIndexByRequest["0:pending"]) - assertEquals(7, exact.reserveAccountIndex(0, "pending").accountIndex) - assertEquals(8, exact.reserveAccountIndex(0, "next").accountIndex) - assertFalse("0:pending" in divergent.pendingAccountIndexByRequest) - assertEquals(9, divergent.reserveAccountIndex(0, "pending").accountIndex) - } - - @Test - fun `restore deterministically keeps one request per free account index`() { - val restored = WatchOnlyAccountData().restoreAccounts( - accounts = emptyList(), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 2), - pendingAccountIndexByRequest = linkedMapOf( - "0:z-request" to 7, - "0:a-request" to 7, - ), - ), - ) - - assertEquals(mapOf("0:a-request" to 7), restored.pendingAccountIndexByRequest) - assertEquals(7, restored.highestAccountIndexByWallet["0"]) - assertEquals(7, restored.reserveAccountIndex(0, "a-request").accountIndex) - assertEquals(8, restored.reserveAccountIndex(0, "z-request").accountIndex) - } - - @Test - fun `restore keeps one deterministic owner for a duplicate id`() { - val completed = account(accountIndex = 7).copy(id = "duplicate-id", createdAt = 2) - val incomplete = account(accountIndex = 3).copy( - id = completed.id, - createdAt = 1, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - - val forward = WatchOnlyAccountData().restoreAccounts( - accounts = listOf(incomplete, completed), - allocationState = WatchOnlyAccountAllocationState( - pendingAccountIndexByRequest = mapOf( - "0:${incomplete.requestFingerprint}" to incomplete.accountIndex, - ), - ), - ) - val reversed = WatchOnlyAccountData().restoreAccounts(listOf(completed, incomplete)) - - assertEquals(listOf(completed), forward.accounts) - assertEquals(forward.accounts, reversed.accounts) - assertEquals(7, forward.highestAccountIndexByWallet["0"]) - assertFalse("0:${incomplete.requestFingerprint}" in forward.pendingAccountIndexByRequest) - } - - @Test - fun `restore rejects a reservation at a discarded account index`() { - val retained = account(accountIndex = 1).copy(id = "duplicate-id") - val discarded = account(accountIndex = 7).copy(id = retained.id) - val restored = WatchOnlyAccountData().restoreAccounts( - accounts = listOf(discarded, retained), - allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 7), - pendingAccountIndexByRequest = mapOf("0:retry" to 7), - ), - ) - - assertFalse("0:retry" in restored.pendingAccountIndexByRequest) - assertEquals(8, restored.reserveAccountIndex(0, "retry").accountIndex) } @Test - fun `restore normalizes tracking for incomplete accounts`() { + 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 disabledActive = account(accountIndex = 3).copy(isTrackingEnabled = false) + 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().restoreAccounts(listOf(pending, authorizing, disabledActive)) + val restored = WatchOnlyAccountData().restoreAccounts( + listOf(pending, authorizing, activeDisabled, duplicateSlot, invalid), + ) - assertFalse(restored.accounts.first { it.id == pending.id }.isTrackingEnabled) - assertTrue(restored.accounts.first { it.id == authorizing.id }.isTrackingEnabled) - assertFalse(restored.accounts.first { it.id == disabledActive.id }.isTrackingEnabled) + 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", - setupState = WatchOnlyAccountSetupState.Authorizing, - ) + val invalidXpub = account(accountIndex = 8).copy(xpub = "not-an-xpub") val accountZero = account(accountIndex = 0) val restored = WatchOnlyAccountData().restoreAccounts( @@ -236,55 +114,6 @@ class WatchOnlyAccountStoreTest { assertEquals(9, restored.reserveAccountIndex(0, "next").accountIndex) } - @Test - fun `restore keeps one deterministic owner for a duplicate management key`() { - val completed = account(accountIndex = 5).copy(id = "completed", createdAt = 2) - val incomplete = completed.copy( - id = "incomplete", - xpub = "other-xpub", - requestFingerprint = "other-request", - createdAt = 1, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - - val forward = WatchOnlyAccountData().restoreAccounts(listOf(incomplete, completed)) - val reversed = WatchOnlyAccountData().restoreAccounts(listOf(completed, incomplete)) - - assertEquals(listOf(completed), forward.accounts) - assertEquals(forward.accounts, reversed.accounts) - } - - @Test - fun `restore keeps one deterministic owner for an incomplete request`() { - val first = account(accountIndex = 4).copy( - id = "first", - requestFingerprint = "shared-request", - createdAt = 1, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - val later = account(accountIndex = 3).copy( - id = "later", - requestFingerprint = first.requestFingerprint, - createdAt = 2, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - val allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 4), - pendingAccountIndexByRequest = mapOf("0:${first.requestFingerprint}" to first.accountIndex), - ) - - val forward = WatchOnlyAccountData().restoreAccounts(listOf(later, first), allocationState) - val reversed = WatchOnlyAccountData().restoreAccounts(listOf(first, later), allocationState) - - assertEquals(listOf(first), forward.accounts) - assertEquals(forward.accounts, reversed.accounts) - assertEquals(first.accountIndex, forward.pendingAccountIndexByRequest["0:${first.requestFingerprint}"]) - assertEquals(4, forward.highestAccountIndexByWallet["0"]) - } - @Test fun `restore persists replaced accounts until runtime reconciliation completes`() { val replacedAccount = account(accountIndex = 1) @@ -343,66 +172,23 @@ class WatchOnlyAccountStoreTest { assertEquals(WatchOnlyAccountSetupState.Active, activated.accounts.single().setupState) assertFalse(requestKey in activated.pendingAccountIndexByRequest) assertEquals(8, activated.pendingAccountIndexByRequest["0:other"]) - assertEquals(7, activated.highestAccountIndexByWallet["0"]) - assertTrue(requestKey in data.pendingAccountIndexByRequest) } @Test - fun `restore preserves and activates authorizing accounts over backup conflicts`() { - val authorizingById = account(accountIndex = 4).copy( - id = "authorizing-by-id", + fun `restore preserves an authorizing account over backup state`() { + val authorizing = account(accountIndex = 4).copy( isTrackingEnabled = true, setupState = WatchOnlyAccountSetupState.Authorizing, ) - val authorizingByIdentity = account(accountIndex = 5).copy( - id = "authorizing-by-identity", - isTrackingEnabled = true, - setupState = WatchOnlyAccountSetupState.Authorizing, - ) - val conflictById = account(accountIndex = 6).copy(id = authorizingById.id) - val conflictByIdentity = authorizingByIdentity.copy( - id = "restored-conflict", - requestFingerprint = "restored-conflict", - setupState = WatchOnlyAccountSetupState.Active, - ) - val restoredAccount = account(accountIndex = 7) - val restoredPendingKey = "0:${restoredAccount.requestFingerprint}" - val data = WatchOnlyAccountData( - accounts = listOf(authorizingById, authorizingByIdentity), - pendingAccountIndexByRequest = mapOf( - "0:${authorizingById.requestFingerprint}" to authorizingById.accountIndex, - "0:${authorizingByIdentity.requestFingerprint}" to authorizingByIdentity.accountIndex, - ), - ) - - val restored = data.restoreAccounts( - accounts = listOf(conflictById, conflictByIdentity, restoredAccount), - allocationState = WatchOnlyAccountAllocationState( - pendingAccountIndexByRequest = mapOf(restoredPendingKey to restoredAccount.accountIndex), - ), - ) - val activated = restored.markAccountActive(authorizingById.id) + val requestKey = "0:${authorizing.requestFingerprint}" + val restoredActive = authorizing.copy(setupState = WatchOnlyAccountSetupState.Active) + val restored = WatchOnlyAccountData( + accounts = listOf(authorizing), + pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex), + ).restoreAccounts(listOf(restoredActive)) - assertEquals( - setOf(authorizingById.id, authorizingByIdentity.id, restoredAccount.id), - restored.accounts.mapTo(mutableSetOf(), WatchOnlyAccountRecord::id), - ) - assertEquals( - WatchOnlyAccountSetupState.Active, - activated.accounts.first { it.id == authorizingById.id }.setupState, - ) - assertTrue(activated.accounts.first { it.id == authorizingById.id }.isTrackingEnabled) - assertEquals(authorizingByIdentity, restored.accounts.first { it.id == authorizingByIdentity.id }) - assertEquals( - authorizingById.accountIndex, - restored.pendingAccountIndexByRequest["0:${authorizingById.requestFingerprint}"], - ) - assertEquals( - authorizingByIdentity.accountIndex, - restored.pendingAccountIndexByRequest["0:${authorizingByIdentity.requestFingerprint}"], - ) - assertFalse(restoredPendingKey in restored.pendingAccountIndexByRequest) - assertTrue(restored.accountsPendingRemoval.isEmpty()) + assertEquals(listOf(authorizing), restored.accounts) + assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey]) } @Test @@ -419,125 +205,6 @@ class WatchOnlyAccountStoreTest { assertTrue(restored.accountsPendingRemoval.isEmpty()) } - @Test - fun `restore prefers a delivered owner over a local pending slot`() { - val localPending = account(accountIndex = 5).copy( - requestFingerprint = "local-pending", - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - val restoredActive = account(accountIndex = 5).copy( - id = "restored-active", - requestFingerprint = "restored-active", - ) - - val restored = WatchOnlyAccountData(accounts = listOf(localPending)).restoreAccounts(listOf(restoredActive)) - - assertEquals(listOf(restoredActive), restored.accounts) - assertTrue(restored.accounts.single().isTrackingEnabled) - assertTrue(restored.accountsPendingRemoval.isEmpty()) - } - - @Test - fun `restore keeps tracking enabled across delivered owner conflicts`() { - val localDisabled = account(accountIndex = 5).copy( - requestFingerprint = "local-active", - isTrackingEnabled = false, - ) - val restoredEnabled = account(accountIndex = 5).copy( - id = "restored-active", - requestFingerprint = "restored-active", - ) - - val restored = WatchOnlyAccountData(accounts = listOf(localDisabled)).restoreAccounts(listOf(restoredEnabled)) - - assertEquals(localDisabled.id, restored.accounts.single().id) - assertTrue(restored.accounts.single().isTrackingEnabled) - } - - @Test - fun `restore protects an owner awaiting removal from a conflicting slot`() { - val local = account(accountIndex = 5) - val conflictingBackup = local.copy( - id = "restored-owner", - requestFingerprint = "restored-request", - ) - val awaitingRemoval = WatchOnlyAccountData(accounts = listOf(local)).restoreAccounts(emptyList()) - - val restored = awaitingRemoval.restoreAccounts(listOf(conflictingBackup)) - - assertEquals(listOf(local), restored.accounts) - assertTrue(restored.accountsPendingRemoval.isEmpty()) - } - - @Test - fun `restore does not regress an active local owner to incomplete`() { - val local = account(accountIndex = 5) - val incompleteBackup = local.copy( - id = "incomplete-backup", - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - - val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreAccounts(listOf(incompleteBackup)) - - assertEquals(listOf(local), restored.accounts) - } - - @Test - fun `restore uses the same canonical incomplete owner regardless of input order`() { - val lowerIndex = account(accountIndex = 3).copy( - id = "z-lower-index", - requestFingerprint = "shared-request", - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - val higherIndex = account(accountIndex = 4).copy( - id = "a-higher-index", - requestFingerprint = lowerIndex.requestFingerprint, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - - val forward = WatchOnlyAccountData().restoreAccounts(listOf(higherIndex, lowerIndex)) - val reversed = WatchOnlyAccountData().restoreAccounts(listOf(lowerIndex, higherIndex)) - - assertEquals(listOf(lowerIndex), forward.accounts) - assertEquals(forward.accounts, reversed.accounts) - } - - @Test - fun `current authorizing account wins a duplicate incomplete request`() { - val authorizing = account(accountIndex = 8).copy( - id = "authorizing", - requestFingerprint = "shared-request", - createdAt = 2, - isTrackingEnabled = true, - setupState = WatchOnlyAccountSetupState.Authorizing, - ) - val restoredDuplicate = account(accountIndex = 1).copy( - id = "restored", - requestFingerprint = authorizing.requestFingerprint, - createdAt = 1, - isTrackingEnabled = false, - setupState = WatchOnlyAccountSetupState.PendingDelivery, - ) - val requestKey = "0:${authorizing.requestFingerprint}" - val restored = WatchOnlyAccountData( - accounts = listOf(authorizing), - pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex), - ).restoreAccounts( - accounts = listOf(restoredDuplicate), - allocationState = WatchOnlyAccountAllocationState( - pendingAccountIndexByRequest = mapOf(requestKey to restoredDuplicate.accountIndex), - ), - ) - - assertEquals(listOf(authorizing), restored.accounts) - assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey]) - assertTrue(restored.accountsPendingRemoval.isEmpty()) - } - @Test fun `activation fails when the account is missing`() { val error = assertFailsWith { diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt index 5093b8bfc4..eeb7b7d394 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt @@ -29,15 +29,14 @@ import kotlin.test.assertEquals class WatchOnlyAccountRestoreTest : BaseUnitTest() { @Test - fun `retry and reconciliation use one canonical incomplete account after restore`() = test { - val canonicalAccount = account(id = "canonical-account", accountIndex = 5, createdAt = 1) - val duplicateAccount = account(id = "duplicate-account", accountIndex = 6, createdAt = 2) + 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(duplicateAccount, canonicalAccount), + accounts = listOf(restoredAccount), allocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet = mapOf("0" to 6), - pendingAccountIndexByRequest = mapOf(requestKey to canonicalAccount.accountIndex), + highestAccountIndexByWallet = mapOf("0" to 5), + pendingAccountIndexByRequest = mapOf(requestKey to restoredAccount.accountIndex), ), ) val store = mock() @@ -54,26 +53,20 @@ class WatchOnlyAccountRestoreTest : BaseUnitTest() { val lightningService = lightningService(store, node, coordinator) val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService, coordinator) - val prepared = sut.prepareUnsignedClaim(AUTH_URL, canonicalAccount.name) + val prepared = sut.prepareUnsignedClaim(AUTH_URL, restoredAccount.name) lightningService.reconcileWatchOnlyAccounts(syncAfterReconcile = false) - assertEquals(listOf(canonicalAccount), storedData.accounts) - assertEquals(canonicalAccount.id, prepared.account.id) - assertEquals(canonicalAccount.accountIndex, prepared.account.accountIndex) + 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(node, never()).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 6u, TEST_XPUB) verify(onchainPayment).revealReceiveAddressesToAccount( AddressType.NATIVE_SEGWIT, 5u, WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), ) - verify(onchainPayment, never()).revealReceiveAddressesToAccount( - AddressType.NATIVE_SEGWIT, - 6u, - WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX.toUInt(), - ) } private fun lightningService( diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index f857a101a0..6aa08e7643 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -47,5 +47,5 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - 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. -- Startup reconciliation restores runtime tracking and the pre-revealed range. Reconciliation is serialized with setup, restore, and tracking mutations so a stale snapshot cannot undo the latest account state. When a backup replaces the account list, references to removed accounts remain locally until reconciliation successfully unloads them. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- 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. From cb8f84a660d766b828627443a494ea6dd094cf86 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 13:13:35 +0200 Subject: [PATCH 09/13] refactor: polish paykit account setup --- app/build.gradle.kts | 3 - .../to/bitkit/data/WatchOnlyAccountStore.kt | 31 ++- .../java/to/bitkit/repositories/BackupRepo.kt | 1 - .../java/to/bitkit/repositories/PubkyRepo.kt | 9 +- .../repositories/WatchOnlyAccountRepo.kt | 25 +- .../to/bitkit/services/LightningService.kt | 3 +- .../java/to/bitkit/services/PubkyService.kt | 12 +- .../java/to/bitkit/ui/components/TextInput.kt | 2 - .../profile/PubkyAuthApprovalViewModel.kt | 28 +- .../ui/settings/AdvancedSettingsViewModel.kt | 7 +- .../advanced/WatchOnlyAccountsScreen.kt | 8 +- .../advanced/WatchOnlyAccountsViewModel.kt | 15 +- app/src/main/res/values/strings.xml | 4 - .../bitkit/data/WatchOnlyAccountStoreTest.kt | 22 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 9 +- .../WatchOnlyAccountClaimCodecTest.kt | 34 +-- ...atchOnlyAccountLifecycleCoordinatorTest.kt | 192 +++++++++++++ .../repositories/WatchOnlyAccountRepoTest.kt | 255 +++--------------- .../WatchOnlyAccountRestoreTest.kt | 9 +- .../profile/PubkyAuthApprovalViewModelTest.kt | 72 +---- docs/watch-only-account-claim-v1.md | 2 +- gradle/libs.versions.toml | 3 +- 22 files changed, 365 insertions(+), 381 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/WatchOnlyAccountLifecycleCoordinatorTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb4b845f9e..88f5f616f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -382,9 +382,6 @@ dependencies { implementation(libs.camera.view) // Crypto implementation(libs.bouncycastle.provider.jdk) - implementation(libs.bitcoinj.core) { - exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") - } implementation(libs.ldk.node.android) { exclude(group = "net.java.dev.jna", module = "jna") } implementation(libs.bitkit.core) implementation(libs.paykit) diff --git a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt index 65a298dae2..4a3947526b 100644 --- a/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt +++ b/app/src/main/java/to/bitkit/data/WatchOnlyAccountStore.kt @@ -3,13 +3,13 @@ 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 org.bitcoinj.base.Base58 import to.bitkit.data.serializers.WatchOnlyAccountDataSerializer import to.bitkit.di.IoDispatcher import to.bitkit.models.WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE @@ -24,10 +24,16 @@ private val Context.watchOnlyAccountDataStore: DataStore b 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 @@ -72,7 +78,9 @@ class WatchOnlyAccountStore @Inject constructor( accounts: List, allocationState: WatchOnlyAccountAllocationState? = null, ) = withContext(ioDispatcher) { - store.updateData { current -> current.restoreAccounts(accounts, allocationState) } + store.updateData { current -> + current.restoreAccounts(accounts, allocationState, xpubSerializer::serialize) + } Unit } @@ -186,16 +194,17 @@ internal fun WatchOnlyAccountData.markAccountActive(id: String): WatchOnlyAccoun internal fun WatchOnlyAccountData.restoreAccounts( accounts: List, allocationState: WatchOnlyAccountAllocationState? = null, + serializeXpub: (String) -> ByteArray, ): WatchOnlyAccountData { - val restoredAccounts = accounts.sanitizedAccounts() + val restoredAccounts = accounts.sanitizedAccounts(serializeXpub) val locallyManagedAccounts = uniqueAccountsByManagementKey(this.accounts + accountsPendingRemoval) val protectedLocalAccounts = locallyManagedAccounts .filter { localAccount -> localAccount.setupState == WatchOnlyAccountSetupState.Authorizing || localAccount.shouldPreserveFrom(restoredAccounts) } - .sanitizedAccounts() - val mergedAccounts = (protectedLocalAccounts + restoredAccounts).sanitizedAccounts() + .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 } @@ -245,19 +254,23 @@ private fun WatchOnlyAccountRecord.normalizedTrackingState(): WatchOnlyAccountRe WatchOnlyAccountSetupState.Active -> this } -private fun WatchOnlyAccountRecord.isUsableAccount(): Boolean = walletIndex >= 0 && +private fun WatchOnlyAccountRecord.isUsableAccount( + serializeXpub: (String) -> ByteArray, +): Boolean = walletIndex >= 0 && accountIndex > 0 && addressType == WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE && - runCatching { Base58.decodeChecked(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH } + runCatching { serializeXpub(xpub).size == WATCH_ONLY_ACCOUNT_SERIALIZED_XPUB_LENGTH } .getOrDefault(false) -private fun List.sanitizedAccounts(): List { +private fun List.sanitizedAccounts( + serializeXpub: (String) -> ByteArray, +): List { val ids = mutableSetOf() val managementKeys = mutableSetOf() val incompleteRequestKeys = mutableSetOf() return mapNotNull { input -> - if (!input.isUsableAccount()) return@mapNotNull null + if (!input.isUsableAccount(serializeXpub)) return@mapNotNull null val account = input.normalizedTrackingState() val incompleteRequestKey = account .takeIf { it.setupState != WatchOnlyAccountSetupState.Active } diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 74818a5e5a..956e6ebfcc 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -572,7 +572,6 @@ class BackupRepo @Inject constructor( return json.encodeToString(payload).toByteArray() } - @Suppress("LongMethod") suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, ): Result = withContext(ioDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index be89220b4e..4df0de0532 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -5,6 +5,7 @@ import android.graphics.BitmapFactory import coil3.ImageLoader import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -931,9 +932,11 @@ class PubkyRepo @Inject constructor( authUrl = authUrl, expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, secretKeyHex = secretKeyHex, - queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - unsignedPayload = unsignedPayload, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + unsignedPayload = unsignedPayload, + ), ) } } diff --git a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt index cdf37d10a0..1df5fe6017 100644 --- a/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WatchOnlyAccountRepo.kt @@ -5,11 +5,11 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import org.bitcoinj.base.Base58 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 @@ -53,8 +53,13 @@ class WatchOnlyAccountRepo @Inject constructor( 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) { @@ -74,7 +79,7 @@ class WatchOnlyAccountRepo @Inject constructor( } return@withLock PreparedWatchOnlyAccountClaim( account = refreshed, - payload = WatchOnlyAccountClaimCodec.encode(refreshed), + payload = WatchOnlyAccountClaimCodec.encode(refreshed, xpubSerializer::serialize), ) } @@ -84,7 +89,7 @@ class WatchOnlyAccountRepo @Inject constructor( id = UUID.randomUUID().toString(), walletIndex = walletIndex, accountIndex = accountIndex, - addressType = ADDRESS_TYPE_NATIVE_SEGWIT, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, xpub = xpub, requestFingerprint = fingerprint, createdAt = nowMillis(), @@ -95,7 +100,7 @@ class WatchOnlyAccountRepo @Inject constructor( store.save(current + account) PreparedWatchOnlyAccountClaim( account = account, - payload = WatchOnlyAccountClaimCodec.encode(account), + payload = WatchOnlyAccountClaimCodec.encode(account, xpubSerializer::serialize), ) } } @@ -230,7 +235,7 @@ class WatchOnlyAccountRepo @Inject constructor( ) = ServiceQueue.LDK.background { val node = lightningService.node ?: throw WatchOnlyAccountError.NodeUnavailable val addressType = when (account.addressType) { - ADDRESS_TYPE_NATIVE_SEGWIT -> AddressType.NATIVE_SEGWIT + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> AddressType.NATIVE_SEGWIT else -> throw WatchOnlyAccountError.InvalidExtendedPublicKey } val accountIndex = account.accountIndex.toUInt() @@ -310,7 +315,6 @@ class WatchOnlyAccountRepo @Inject constructor( } companion object { - const val ADDRESS_TYPE_NATIVE_SEGWIT = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE private const val MAX_NAME_LENGTH = 64 } } @@ -329,9 +333,12 @@ object WatchOnlyAccountClaimCodec { 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): ByteArray { - val rawXpub = if (account.addressType == WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT) { - runCatching { Base58.decodeChecked(account.xpub) }.getOrNull() + 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 } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 455aab741f..dc46c1df95 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -381,7 +381,8 @@ class LightningService @Inject constructor( val trackedAccounts = node.listOnchainWalletAccounts() val managedKeys = (walletRecords + accountsPendingRemoval).mapNotNull { record -> when (record.addressType) { - "nativeSegwit" -> accountKey(LdkAddressType.NATIVE_SEGWIT, record.accountIndex.toUInt()) + WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE -> + accountKey(LdkAddressType.NATIVE_SEGWIT, record.accountIndex.toUInt()) else -> null } }.toSet() diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index e91106327d..cc6cd7360b 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -10,7 +10,7 @@ import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("TooManyFunctions") @Singleton class PubkyService @Inject constructor( private val paykitSdkService: PaykitSdkService, @@ -122,19 +122,13 @@ class PubkyService @Inject constructor( authUrl: String, expectedCapabilities: String, secretKeyHex: String, - queryParameter: String, - claimType: String, - unsignedPayload: ByteArray, + claim: PubkyAuthCompanionClaim, ) = ServiceQueue.CORE.background { paykitSdkService.approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, secretKeyHex = secretKeyHex, - claim = PubkyAuthCompanionClaim( - queryParameter = queryParameter, - claimType = claimType, - unsignedPayload = unsignedPayload, - ), + claim = claim, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/TextInput.kt b/app/src/main/java/to/bitkit/ui/components/TextInput.kt index 23acfc66d9..7f4d524fbf 100644 --- a/app/src/main/java/to/bitkit/ui/components/TextInput.kt +++ b/app/src/main/java/to/bitkit/ui/components/TextInput.kt @@ -34,7 +34,6 @@ fun TextInput( placeholder: String? = null, value: String, onValueChange: (String) -> Unit, - enabled: Boolean = true, singleLine: Boolean = false, isError: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, @@ -70,7 +69,6 @@ fun TextInput( } TextField( - enabled = enabled, placeholder = { placeholder?.let { Text(placeholder, color = Colors.White64, style = textStyle) 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 8a04efc2e7..c345d52290 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 @@ -84,12 +84,6 @@ class PubkyAuthApprovalViewModel @Inject constructor( imageUrl = pubkyRepo.displayImageUri.value, ) } - val accountName = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { - context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName) - } else { - "" - } - _uiState.update { it.copy( state = if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { @@ -98,10 +92,8 @@ class PubkyAuthApprovalViewModel @Inject constructor( ApprovalState.Authorize }, serviceName = serviceName, - requestedCapabilities = request.capabilities, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, - watchOnlyAccountName = accountName, profile = profile, ) } @@ -163,20 +155,20 @@ class PubkyAuthApprovalViewModel @Inject constructor( viewModelScope.launch { try { - authorize(authUrl, authorizingState.watchOnlyAccountName) + authorize(authUrl) } finally { inFlightAuthorization.compareAndSet(authorization, null) } } } - private suspend fun authorize(authUrl: String, watchOnlyAccountName: String) { + 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, watchOnlyAccountName)) return + if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) _uiState.update { state -> @@ -187,17 +179,15 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, - watchOnlyAccountName: String, - ): Boolean = resumeLocalActivation(authUrl) ?: approveNewRequest(request, authUrl, watchOnlyAccountName) + ): Boolean = resumeLocalActivation(authUrl) ?: approveNewRequest(request, authUrl) private suspend fun approveNewRequest( request: PubkyAuthRequest, authUrl: String, - watchOnlyAccountName: String, ): Boolean { val preparedClaim = runSuspendCatching { if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { - watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, watchOnlyAccountName) + watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, defaultWatchOnlyAccountName(request)) } else { null } @@ -311,6 +301,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( ) } + 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) } } @@ -321,10 +317,8 @@ 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 watchOnlyAccountName: String = "", val profile: PubkyProfile? = null, ) 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 b1b0bd87e8..d838f61b93 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -15,7 +15,6 @@ import to.bitkit.models.addressTypeInfo import to.bitkit.models.toAddressType import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WatchOnlyAccountRepo -import to.bitkit.services.LightningService import javax.inject.Inject private const val NODE_ID_PREFIX_LENGTH = 5 @@ -26,11 +25,8 @@ class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, watchOnlyAccountRepo: WatchOnlyAccountRepo, - lightningService: LightningService, ) : ViewModel() { - private val walletIndex = lightningService.currentWalletIndex - val selectedAddressTypeName = settingsStore.data .map { it.selectedAddressType.toAddressType()?.addressTypeInfo()?.shortName ?: "" } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") @@ -39,8 +35,7 @@ class AdvancedSettingsViewModel @Inject constructor( .map { it.channels.filterOpen().size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) - val watchOnlyAccountCount = watchOnlyAccountRepo.accounts - .map { accounts -> accounts.count { it.walletIndex == walletIndex } } + val watchOnlyAccountCount = watchOnlyAccountRepo.currentWalletAccountCount .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) val truncatedNodeId = lightningRepo.lightningState 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 index c27dbafd9d..247e35a2f5 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsScreen.kt @@ -54,11 +54,11 @@ fun WatchOnlyAccountsScreen( viewModel: WatchOnlyAccountsViewModel = hiltViewModel(), ) { val accounts by viewModel.accounts.collectAsStateWithLifecycle() - val updatingAccountId by viewModel.updatingAccountId.collectAsStateWithLifecycle() + val isUpdating by viewModel.isUpdating.collectAsStateWithLifecycle() Content( accounts = accounts, - updatingAccountId = updatingAccountId, + isUpdating = isUpdating, onBack = { navController.popBackStack() }, onRename = viewModel::rename, onTrackingChange = viewModel::setTrackingEnabled, @@ -68,7 +68,7 @@ fun WatchOnlyAccountsScreen( @Composable private fun Content( accounts: ImmutableList, - updatingAccountId: String?, + isUpdating: Boolean, onBack: () -> Unit, onRename: (WatchOnlyAccountRecord, String) -> Unit, onTrackingChange: (WatchOnlyAccountRecord, Boolean) -> Unit, @@ -104,7 +104,7 @@ private fun Content( items(activeAccounts, key = WatchOnlyAccountRecord::id) { account -> ActiveAccountRows( account = account, - isUpdating = updatingAccountId != null, + isUpdating = isUpdating, onOpenDetails = { selectedAccount = account }, onTrackingChange = onTrackingChange, ) 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 index a3c49506f9..09d8f925dc 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/WatchOnlyAccountsViewModel.kt @@ -19,7 +19,6 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.repositories.WatchOnlyAccountRepo -import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.utils.localizedPubkyAuthMessage import javax.inject.Inject @@ -28,14 +27,12 @@ import javax.inject.Inject class WatchOnlyAccountsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val watchOnlyAccountRepo: WatchOnlyAccountRepo, - lightningService: LightningService, ) : ViewModel() { - private val walletIndex = lightningService.currentWalletIndex - private val _updatingAccountId = MutableStateFlow(null) - val updatingAccountId = _updatingAccountId.asStateFlow() + private val _isUpdating = MutableStateFlow(false) + val isUpdating = _isUpdating.asStateFlow() - val accounts = watchOnlyAccountRepo.accounts - .map { accounts -> accounts.filter { it.walletIndex == walletIndex }.toImmutableList() } + val accounts = watchOnlyAccountRepo.currentWalletAccounts + .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) fun rename(account: WatchOnlyAccountRecord, name: String) { @@ -53,7 +50,7 @@ class WatchOnlyAccountsViewModel @Inject constructor( fun setTrackingEnabled(account: WatchOnlyAccountRecord, enabled: Boolean) { viewModelScope.launch { - _updatingAccountId.update { account.id } + _isUpdating.update { true } try { runSuspendCatching { watchOnlyAccountRepo.setTrackingEnabled(account.id, enabled) @@ -70,7 +67,7 @@ class WatchOnlyAccountsViewModel @Inject constructor( ) }.onFailure { error -> showError(error) } } finally { - _updatingAccountId.update { null } + _isUpdating.update { false } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a41b35f953..e205f76fb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -575,10 +575,6 @@ Authorize Make sure you trust the service, browser, or device before authorizing with your pubky. %1$s server - Share a watch-only Bitcoin account. This lets the service generate receive addresses and view this account’s transactions and balance, but not spend funds. - ACCOUNT NAME - Name this account - Watch-only Bitcoin account Approve To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds. Earn diff --git a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt index a76c848259..b8718d83bf 100644 --- a/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/WatchOnlyAccountStoreTest.kt @@ -50,7 +50,7 @@ class WatchOnlyAccountStoreTest { pendingAccountIndexByRequest = mapOf("0:pending" to 10), ) - val restored = data.restoreAccounts(listOf(account(accountIndex = 7))) + val restored = data.restoreTestAccounts(listOf(account(accountIndex = 7))) assertEquals(emptyMap(), restored.pendingAccountIndexByRequest) assertEquals(11, restored.reserveAccountIndex(walletIndex = 0, requestFingerprint = "pending").accountIndex) @@ -63,7 +63,7 @@ class WatchOnlyAccountStoreTest { pendingAccountIndexByRequest = mapOf("0:pending" to 7), ) - val restored = WatchOnlyAccountData().restoreAccounts( + val restored = WatchOnlyAccountData().restoreTestAccounts( accounts = listOf(account(accountIndex = 5)), allocationState = allocationState, ) @@ -91,7 +91,7 @@ class WatchOnlyAccountStoreTest { ) val invalid = account(accountIndex = 4).copy(xpub = "invalid") - val restored = WatchOnlyAccountData().restoreAccounts( + val restored = WatchOnlyAccountData().restoreTestAccounts( listOf(pending, authorizing, activeDisabled, duplicateSlot, invalid), ) @@ -106,7 +106,7 @@ class WatchOnlyAccountStoreTest { val invalidXpub = account(accountIndex = 8).copy(xpub = "not-an-xpub") val accountZero = account(accountIndex = 0) - val restored = WatchOnlyAccountData().restoreAccounts( + val restored = WatchOnlyAccountData().restoreTestAccounts( listOf(invalidXpub, accountZero, invalidAddressType, valid), ) @@ -120,7 +120,7 @@ class WatchOnlyAccountStoreTest { val restoredAccount = account(accountIndex = 2) val restored = WatchOnlyAccountData(accounts = listOf(replacedAccount)) - .restoreAccounts(accounts = listOf(restoredAccount)) + .restoreTestAccounts(accounts = listOf(restoredAccount)) val reloaded = json.decodeFromString(json.encodeToString(restored)) val otherWalletPendingRemoval = account(accountIndex = 3, walletIndex = 1) @@ -185,7 +185,7 @@ class WatchOnlyAccountStoreTest { val restored = WatchOnlyAccountData( accounts = listOf(authorizing), pendingAccountIndexByRequest = mapOf(requestKey to authorizing.accountIndex), - ).restoreAccounts(listOf(restoredActive)) + ).restoreTestAccounts(listOf(restoredActive)) assertEquals(listOf(authorizing), restored.accounts) assertEquals(authorizing.accountIndex, restored.pendingAccountIndexByRequest[requestKey]) @@ -199,7 +199,7 @@ class WatchOnlyAccountStoreTest { requestFingerprint = "restored-request", ) - val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreAccounts(listOf(conflictingBackup)) + val restored = WatchOnlyAccountData(accounts = listOf(local)).restoreTestAccounts(listOf(conflictingBackup)) assertEquals(listOf(local), restored.accounts) assertTrue(restored.accountsPendingRemoval.isEmpty()) @@ -226,4 +226,12 @@ class WatchOnlyAccountStoreTest { 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/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 49222559ef..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 @@ -222,9 +223,11 @@ class PubkyRepoTest : BaseUnitTest() { authUrl = authUrl, expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, secretKeyHex = secretKey, - queryParameter = PubkyAuthClaim.QUERY_PARAMETER, - claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, - unsignedPayload = payload, + claim = PubkyAuthCompanionClaim( + queryParameter = PubkyAuthClaim.QUERY_PARAMETER, + claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, + unsignedPayload = payload, + ), ) } } diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt index 8bcdcd16f5..dfcee0ac5e 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountClaimCodecTest.kt @@ -1,11 +1,10 @@ package to.bitkit.repositories -import org.bitcoinj.base.Base58 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 java.security.MessageDigest import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -13,10 +12,13 @@ import kotlin.test.assertFailsWith class WatchOnlyAccountClaimCodecTest { @Test fun `unsigned claim contains exact account metadata`() { - val rawXpub = ByteArray(WatchOnlyAccountClaimCodec.SERIALIZED_XPUB_LENGTH) { it.toByte() } - val account = account(accountIndex = 42, xpub = base58CheckEncode(rawXpub)) + 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) + val payload = WatchOnlyAccountClaimCodec.encode(account) { xpub -> + require(xpub == TESTNET_TPUB) + rawXpub + } assertEquals(84, payload.size) assertEquals(WatchOnlyAccountClaimCodec.PAYLOAD_LENGTH, payload.size) @@ -28,12 +30,12 @@ class WatchOnlyAccountClaimCodecTest { @Test fun `unsigned claim rejects invalid Base58Check checksum`() { - val rawXpub = ByteArray(WatchOnlyAccountClaimCodec.SERIALIZED_XPUB_LENGTH) { it.toByte() } - val validXpub = base58CheckEncode(rawXpub) - val invalidXpub = validXpub.dropLast(1) + if (validXpub.last() == '1') '2' else '1' + val invalidXpub = TESTNET_TPUB.dropLast(1) + if (TESTNET_TPUB.last() == '1') '2' else '1' assertFailsWith { - WatchOnlyAccountClaimCodec.encode(account(accountIndex = 1, xpub = invalidXpub)) + WatchOnlyAccountClaimCodec.encode(account(accountIndex = 1, xpub = invalidXpub)) { + throw IllegalArgumentException("Invalid extended public key") + } } } @@ -41,7 +43,7 @@ class WatchOnlyAccountClaimCodecTest { id = "id", walletIndex = 0, accountIndex = accountIndex, - addressType = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, xpub = xpub, requestFingerprint = "request", createdAt = 1, @@ -50,10 +52,12 @@ class WatchOnlyAccountClaimCodecTest { setupState = WatchOnlyAccountSetupState.PendingDelivery, ) - private fun base58CheckEncode(payload: ByteArray): String { - val checksum = sha256(sha256(payload)).copyOf(4) - return Base58.encode(payload + checksum) + private companion object { + const val TESTNET_TPUB = + "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" + const val TESTNET_SERIALIZED_HEX = + "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" } - - private fun sha256(value: ByteArray) = MessageDigest.getInstance("SHA-256").digest(value) } 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 index b77124cca9..de15f212d0 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRepoTest.kt @@ -2,6 +2,7 @@ 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 @@ -18,26 +19,19 @@ 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.WatchOnlyAccountAllocationState import to.bitkit.data.WatchOnlyAccountData -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.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 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.assertFailsWith import kotlin.test.assertFalse @@ -46,18 +40,28 @@ import kotlin.test.assertSame import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("LargeClass") class WatchOnlyAccountRepoTest : BaseUnitTest() { @Test - fun `authorization fails before tracking when the prepared account is missing`() = 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() - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), + 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()) @@ -75,12 +79,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { val lightningService = mock() whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData())) whenever(store.load()).thenReturn(emptyList()) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) assertFailsWith { sut.markActive("missing") @@ -99,12 +98,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { val releaseLock = CompletableDeferred() whenever(store.data).thenReturn(flowOf(WatchOnlyAccountData(accounts = listOf(account)))) whenever(store.load()).thenReturn(listOf(account)) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - coordinator, - ) + val sut = repository(store, lightningService, coordinator) val lockHolder = launch { coordinator.withLock { lockAcquired.complete(Unit) @@ -141,12 +135,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { whenever(store.reserveAccountIndex(any(), any())).thenReturn(1) whenever(lightningService.node).thenReturn(node) whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 1u)).thenReturn(TEST_XPUB) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val first = sut.prepareUnsignedClaim( "pubkyauth://signin?relay=https%3A%2F%2Frelay.example&secret=same&" + @@ -190,6 +179,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { highestAccountIndexByWallet = mapOf("0" to 5), pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5), ), + serializeXpub = { ByteArray(78) }, ) assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest) val store = mock() @@ -212,12 +202,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { whenever(lightningService.currentWalletIndex).thenReturn(0) whenever(lightningService.node).thenReturn(node) whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server") @@ -249,6 +234,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { highestAccountIndexByWallet = mapOf("0" to 5), pendingAccountIndexByRequest = mapOf(restoredRequestKey to 5), ), + serializeXpub = { ByteArray(78) }, ) assertEquals(listOf(activeAccount), storedData.accountsPendingRemoval) assertFalse(restoredRequestKey in storedData.pendingAccountIndexByRequest) @@ -272,12 +258,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { whenever(lightningService.currentWalletIndex).thenReturn(0) whenever(lightningService.node).thenReturn(node) whenever(node.exportOnchainWalletAccountXpub(AddressType.NATIVE_SEGWIT, 6u)).thenReturn(TEST_XPUB) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val prepared = sut.prepareUnsignedClaim(RESTORED_AUTH_URL, "Restored server") @@ -315,12 +296,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { node ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) sut.beginAuthorization(account.id) sut.cancelAuthorization(account.id) @@ -362,12 +338,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { whenever(node.listOnchainWalletAccounts()).thenReturn( listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)), ) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val preserveAuthorizingState = sut.beginAuthorization(account.id) sut.cancelAuthorization(account.id, preserveAuthorizingState) @@ -393,12 +364,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)), ) doThrow(unloadError).whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull() @@ -428,12 +394,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { doAnswer { isTracked = false }.whenever(node).removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) doAnswer { isTracked = true }.whenever(node) .addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val error = runCatching { sut.cancelAuthorization(account.id) }.exceptionOrNull() @@ -470,12 +431,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { listOf(OnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u)) ) whenever(node.onchainPayment()).thenReturn(onchainPayment) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) sut.beginAuthorization(account.id) @@ -512,12 +468,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { doAnswer { isTracked = false }.whenever(node) .removeOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u) whenever(node.syncWallets()).thenThrow(syncError) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) val error = runCatching { sut.beginAuthorization(account.id) }.exceptionOrNull() @@ -557,12 +508,7 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { doAnswer { isTracked = true }.whenever( node ).addOnchainWalletAccount(AddressType.NATIVE_SEGWIT, 1u, account.xpub) - val sut = WatchOnlyAccountRepo( - testDispatcher, - store, - lightningService, - WatchOnlyAccountLifecycleCoordinator(), - ) + val sut = repository(store, lightningService) sut.setTrackingEnabled(account.id, enabled = false) @@ -581,136 +527,21 @@ class WatchOnlyAccountRepoTest : BaseUnitTest() { verify(node, times(1)).syncWallets() } - @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 = WatchOnlyAccountRepo(testDispatcher, 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 = WatchOnlyAccountRepo(testDispatcher, 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( + private fun repository( 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 } + 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 = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, xpub = "xpub", requestFingerprint = "request", createdAt = 1, diff --git a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt index eeb7b7d394..c3c748c8e2 100644 --- a/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WatchOnlyAccountRestoreTest.kt @@ -15,10 +15,12 @@ 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 @@ -38,6 +40,7 @@ class WatchOnlyAccountRestoreTest : BaseUnitTest() { highestAccountIndexByWallet = mapOf("0" to 5), pendingAccountIndexByRequest = mapOf(requestKey to restoredAccount.accountIndex), ), + serializeXpub = { ByteArray(78) }, ) val store = mock() val node = mock() @@ -51,7 +54,9 @@ class WatchOnlyAccountRestoreTest : BaseUnitTest() { whenever(node.listOnchainWalletAccounts()).thenReturn(emptyList()) whenever(node.onchainPayment()).thenReturn(onchainPayment) val lightningService = lightningService(store, node, coordinator) - val sut = WatchOnlyAccountRepo(testDispatcher, store, lightningService, 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) @@ -87,7 +92,7 @@ class WatchOnlyAccountRestoreTest : BaseUnitTest() { id = id, walletIndex = 0, accountIndex = accountIndex, - addressType = WatchOnlyAccountRepo.ADDRESS_TYPE_NATIVE_SEGWIT, + addressType = WATCH_ONLY_ACCOUNT_NATIVE_SEGWIT_ADDRESS_TYPE, xpub = TEST_XPUB, requestFingerprint = REQUEST_FINGERPRINT, createdAt = createdAt, 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 50a4d84d8d..e6efd06f2d 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 @@ -7,6 +7,7 @@ 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.doReturn import org.mockito.kotlin.doSuspendableAnswer @@ -45,6 +46,15 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } 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`() { val sut = createSut() @@ -76,8 +86,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(currentAuthUrl) }.thenReturn( Result.success(authRequest(currentAuthUrl, "/pub/current/:rw")), ) @@ -98,9 +106,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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(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(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities)), Result.failure(IllegalArgumentException("request changed")), @@ -120,11 +125,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { @Test fun `load exposes watch-only account claim for approval`() = test { val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" - whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") - whenever( - context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") - ).thenReturn("paykit server") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( @@ -141,7 +141,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.WatchOnlyConsent, sut.uiState.value.state) assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, sut.uiState.value.bitkitClaim) - assertEquals("paykit server", sut.uiState.value.watchOnlyAccountName) sut.confirmAuthorize(authUrl) advanceUntilIdle() @@ -169,11 +168,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") - whenever( - context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") - ).thenReturn("paykit server") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -206,11 +200,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") - whenever( - context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") - ).thenReturn("paykit server") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -245,11 +234,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { payload = ByteArray(84), ) val approvalResult = CompletableDeferred>() - whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") - whenever( - context.getString(R.string.profile__auth_approval_watch_only_account_default_name, "paykit") - ).thenReturn("paykit server") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -307,12 +291,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { val authorizationError = PubkyAuthCompanionClaimApprovalException.AuthorizationFailure( "AuthToken delivery failed" ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -342,12 +320,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -381,12 +353,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ), payload = ByteArray(84), ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1)), ) @@ -417,12 +383,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( @@ -456,12 +416,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( @@ -500,12 +454,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { account = watchOnlyAccount(), payload = ByteArray(84), ) - 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") - whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success( authRequest( diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index 6aa08e7643..2f1f3c6abc 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -8,7 +8,7 @@ This document records the client contract implemented by Bitkit iOS and Android - 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. -- The user assigns a local name to the account. The name is not disclosed in the claim. +- 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b41bcb773..596a7e38e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,9 +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" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.4.2" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc36" } -bitcoinj-core = { module = "org.bitcoinj:bitcoinj-core", version = "0.17" } 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" } From 824afd5893459c2c3487848038f51200802c33c6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 15:22:01 +0200 Subject: [PATCH 10/13] fix: simplify watch-only auth retries --- .../profile/PubkyAuthApprovalViewModel.kt | 22 +------ .../profile/PubkyAuthApprovalViewModelTest.kt | 59 +++++++++++++++---- docs/watch-only-account-claim-v1.md | 6 +- 3 files changed, 50 insertions(+), 37 deletions(-) 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 c345d52290..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 @@ -50,8 +50,6 @@ class PubkyAuthApprovalViewModel @Inject constructor( val effects = _effects.asSharedFlow() private val inFlightAuthorization = AtomicReference() - private val accountIdsAwaitingLocalActivation = mutableMapOf() - fun load(authUrl: String) { inFlightAuthorization.get()?.takeIf { it.authUrl == authUrl }?.let { authorization -> if (_uiState.value.authUrl != authUrl) { @@ -179,11 +177,6 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, - ): Boolean = resumeLocalActivation(authUrl) ?: approveNewRequest(request, authUrl) - - private suspend fun approveNewRequest( - request: PubkyAuthRequest, - authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { @@ -214,7 +207,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) if (approvalResult.isFailure) { - val approvalError = approvalResult.exceptionOrNull() ?: IllegalStateException("Authorization failed") + val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } preparedClaim?.let { claim -> if (!approvalError.isPostDeliveryAuthorizationFailure()) { cancelIncompleteSetup( @@ -228,27 +221,14 @@ class PubkyAuthApprovalViewModel @Inject constructor( } preparedClaim?.let { claim -> - accountIdsAwaitingLocalActivation[authUrl] = claim.account.id runSuspendCatching { watchOnlyAccountRepo.markActive(claim.account.id) }.getOrElse { handleApprovalFailure(it, authUrl) return false } - accountIdsAwaitingLocalActivation.remove(authUrl, claim.account.id) } return true } - private suspend fun resumeLocalActivation(authUrl: String): Boolean? { - val accountId = accountIdsAwaitingLocalActivation[authUrl] ?: return null - val result = runSuspendCatching { watchOnlyAccountRepo.markActive(accountId) } - if (result.isSuccess) { - accountIdsAwaitingLocalActivation.remove(authUrl, accountId) - return true - } - handleApprovalFailure(result.exceptionOrNull() ?: IllegalStateException("Account activation failed"), authUrl) - return false - } - private fun transitionToAuthorizing(authUrl: String): PubkyAuthApprovalUiState? { val initialState = _uiState.value if ( 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 e6efd06f2d..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 @@ -9,6 +9,7 @@ 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 @@ -122,6 +123,27 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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}" @@ -448,12 +470,18 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { } @Test - fun `activation persistence retry completes locally without repeating authorization`() = 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( @@ -463,31 +491,36 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ), ), ) - whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) - whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) + 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 sut = createSut() + val initialSut = createSut() - sut.load(authUrl) + initialSut.load(authUrl) advanceUntilIdle() - sut.approveWatchOnlyConsent(authUrl) - sut.confirmAuthorize(authUrl) + initialSut.approveWatchOnlyConsent(authUrl) + initialSut.confirmAuthorize(authUrl) advanceUntilIdle() - assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + assertEquals(ApprovalState.Authorize, initialSut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } verifyBlocking(watchOnlyAccountRepo, never()) { cancelAuthorization(prepared.account.id) } - sut.confirmAuthorize(authUrl) + val restartedSut = createSut() + restartedSut.load(authUrl) + advanceUntilIdle() + restartedSut.approveWatchOnlyConsent(authUrl) + restartedSut.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) } + 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) } } diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index 2f1f3c6abc..bc043e9169 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -41,9 +41,9 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - 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 retries the same pending claim idempotently. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Success marks it active and leaves tracking enabled; 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. Retrying the same request can then finish normal authorization without losing visibility into addresses the server may already have derived. +- 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. From 5ea229bf23c8e6649149d3ac46c269d2fb4c36f2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 17:17:24 +0200 Subject: [PATCH 11/13] fix: align Paykit auth UI --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 3 +++ .../main/java/to/bitkit/ui/components/Text.kt | 2 ++ .../screens/profile/PubkyAuthApprovalSheet.kt | 19 +++++++++++++++---- .../to/bitkit/models/PubkyAuthRequestTest.kt | 12 ++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 68bdd20a10..a212b0b259 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -34,6 +34,9 @@ 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) { 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 ddb8d12362..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 @@ -28,6 +28,7 @@ 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 @@ -274,6 +275,8 @@ private fun ColumnScope.WatchOnlyConsentContent( .padding(horizontal = 16.dp) .testTag("PubkyAuthWatchOnlyConsent") ) { + FillHeight(min = 26.dp) + Image( painter = painterResource(R.drawable.coin_stack), contentDescription = null, @@ -294,7 +297,7 @@ private fun ColumnScope.WatchOnlyConsentContent( color = Colors.White64, ) - FillHeight(min = 12.dp) + VerticalSpacer(32.dp) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( @@ -373,6 +376,8 @@ private fun ColumnScope.ApprovalDetails( uiState: PubkyAuthApprovalUiState, ) { Column(modifier = Modifier.weight(1f)) { + VerticalSpacer(26.dp) + DescriptionText(serviceName = uiState.serviceName) VerticalSpacer(32.dp) @@ -383,7 +388,7 @@ private fun ColumnScope.ApprovalDetails( VerticalSpacer(16.dp) uiState.profile?.let { ProfileCard(it) } - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) } } @@ -392,6 +397,8 @@ private fun ColumnScope.SuccessContent( uiState: PubkyAuthApprovalUiState, onDismiss: () -> Unit, ) { + VerticalSpacer(26.dp) + SuccessDescriptionText( serviceName = uiState.serviceName, truncatedKey = uiState.profile?.authDisplayPublicKey.orEmpty(), @@ -466,7 +473,7 @@ private fun PermissionRow(permission: PubkyAuthPermission) { ) HorizontalSpacer(4.dp) BodySSB( - text = permission.path, + text = permission.displayPath, modifier = Modifier.weight(1f), ) Text13Up( @@ -519,7 +526,11 @@ private fun ProfileCard(profile: PubkyProfile) { } VerticalSpacer(16.dp) - Headline(text = AnnotatedString(profile.name)) + Headline( + text = AnnotatedString(profile.name), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) } } diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index e53432f5dd..db00877783 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -138,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/")) From f0bfb2e50ff39d910ef79e7964bde6407b2561ef Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 13:18:07 +0200 Subject: [PATCH 12/13] feat: add Paykit receiver Noise keys --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../bitkit/repositories/PrivatePaykitRepo.kt | 8 +- .../to/bitkit/services/PaykitSdkService.kt | 123 ++++++++++++++++-- .../repositories/PrivatePaykitRepoTest.kt | 9 +- .../bitkit/services/PaykitSdkServiceTest.kt | 47 +++++++ gradle/libs.versions.toml | 2 +- 6 files changed, 167 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index f50a44b425..37eed460f1 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -232,6 +232,7 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, + PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, PUBKY_SECRET_KEY, } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 45c7ca6dda..8a2ae1f453 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -615,7 +615,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun prepareRelevantPrivateLinksIfAvailable(publicKeys: Collection, reason: String) { - if (!hasLocalSecretKeyForCurrentProfile()) return + if (!hasLiveSessionForCurrentProfile()) return val retryKeys = mutableListOf() for (publicKey in publicKeys) { @@ -1233,16 +1233,16 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun canPublishPrivateEndpoints(): Boolean { val settings = settingsStore.data.first() return settings.sharesPrivatePaykitEndpoints && - hasLocalSecretKeyForCurrentProfile() && + hasLiveSessionForCurrentProfile() && App.currentActivity?.value != null && walletRepo.walletExists() && lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching { + private suspend fun hasLiveSessionForCurrentProfile(): Boolean = runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching false val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false - status.privateLinkCapable + status.liveSessionAvailable }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c30bb05466..32284da951 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -33,6 +33,7 @@ import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult +import com.synonym.paykit.ReceiverNoiseSecretKey import com.synonym.paykit.ReceivingDetail import com.synonym.paykit.ReceivingDetailReservationResponse import com.synonym.paykit.ReceivingDetailReservationResponseKind @@ -67,6 +68,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError +import to.bitkit.utils.Logger import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -129,7 +131,10 @@ class PaykitSdkService @Inject constructor( try { PaykitAndroid.initializeOrThrow(context) operationMutex.withLock { - handle().initialize() + sessionProvider.loadOrCreateReceiverNoiseSecretKey() + val handle = handle() + handle.initialize() + publishReceiverMarkerIfLiveSessionAvailable(handle) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -162,9 +167,11 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() val result = PubkySessionBootstrap().importSession( sessionSecret = secret, localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, + receiverNoiseSecretKey = receiverNoiseSecretKey, requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), ) operationMutex.withLock { @@ -185,8 +192,10 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() val result = PubkySessionBootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = receiverNoiseSecretKey, homeserverPublicKey = homeserverPublicKey, signupCode = signupCode, requiredCapabilities = requiredCapabilities(), @@ -205,8 +214,10 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() val result = PubkySessionBootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = receiverNoiseSecretKey, requiredCapabilities = requiredCapabilities(), ) operationMutex.withLock { @@ -238,6 +249,7 @@ class PaykitSdkService @Inject constructor( try { request.complete( localSecretKey = null, + receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ).also { activateBootstrapResult( @@ -443,15 +455,7 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - val status = handle.identityStatus() - handle.publishPaykitReceiverMarker( - PaykitReceiverCapabilities( - privatePayments = status?.privateLinkCapable == true, - paymentRequests = false, - receipts = false, - outgoingPayments = true, - ), - ) + handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) } } } @@ -660,6 +664,7 @@ class PaykitSdkService @Inject constructor( shouldStoreLocalSecret: Boolean, ) { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) + sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) val localSecret = access.exportLocalSecretKey() if (shouldStoreLocalSecret && localSecret != null) { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) @@ -679,7 +684,30 @@ class PaykitSdkService @Inject constructor( keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } resetRuntime() - handle().initialize() + val handle = handle() + handle.initialize() + publishReceiverMarkerIfLiveSessionAvailable(handle) + } + + private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { + runSuspendCatching { + val capabilities = receiverCapabilities(handle) + if (capabilities.privatePayments) { + handle.publishPaykitReceiverMarker(capabilities) + } + }.onFailure { + Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + } + } + + private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { + val status = handle.identityStatus() + return PaykitReceiverCapabilities( + privatePayments = status?.liveSessionAvailable == true, + paymentRequests = false, + receipts = false, + outgoingPayments = true, + ) } private fun notifyBackupStateChanged() { @@ -734,6 +762,8 @@ class PaykitSdkService @Inject constructor( this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true companion object { + private const val TAG = "PaykitSdkService" + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) @@ -806,6 +836,7 @@ private class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { private val lock = Any() + private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { @@ -830,6 +861,7 @@ private class PaykitSdkSessionProvider( return PubkySessionAccess( sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), + receiverNoiseSecretKey = loadOrCreateReceiverNoiseSecretKey(), ) } @@ -849,6 +881,75 @@ private class PaykitSdkSessionProvider( ?: return null return PaykitSdkService.localSecretKey(secretKeyHex) } + + fun loadOrCreateReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = + receiverNoiseKeyStore.loadOrCreate() + + fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { + receiverNoiseKeyStore.persist(key) + } +} + +internal class PaykitReceiverNoiseKeyStore( + private val loadBytes: () -> ByteArray?, + private val upsertBytes: (ByteArray) -> Unit, +) { + constructor(keychain: Keychain) : this( + loadBytes = { + keychain.accessBlocking { + load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) + } + }, + upsertBytes = { bytes -> + keychain.accessBlocking { + upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes) + } + }, + ) + + @Synchronized + fun loadOrCreate(): ReceiverNoiseSecretKey { + val bytes = loadOrCreateBytes { ReceiverNoiseSecretKey.random().exportBytes() } + return ReceiverNoiseSecretKey(bytes) + } + + @Synchronized + fun persist(key: ReceiverNoiseSecretKey) { + persistBytes(key.exportBytes()) + } + + @Synchronized + internal fun loadOrCreateBytes(generateBytes: () -> ByteArray): ByteArray { + loadBytes()?.let { storedBytes -> + checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid") + return storedBytes + } + + val bytes = generateBytes() + checkKeyLength(bytes, "Generated Paykit receiver Noise key is invalid") + upsertBytes(bytes) + return bytes + } + + @Synchronized + internal fun persistBytes(bytes: ByteArray) { + checkKeyLength(bytes, "Paykit returned an invalid receiver Noise key") + loadBytes()?.let { storedBytes -> + if (!storedBytes.contentEquals(bytes)) { + throw AppError("Paykit receiver Noise key changed unexpectedly") + } + return + } + upsertBytes(bytes) + } + + private fun checkKeyLength(bytes: ByteArray, message: String) { + if (bytes.size != RECEIVER_NOISE_KEY_LENGTH) throw AppError(message) + } + + private companion object { + const val RECEIVER_NOISE_KEY_LENGTH = 32 + } } class PaykitSdkPaymentAdapter : SdkPaymentAdapter { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 20d62407fc..9970ce3784 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -14,7 +14,6 @@ import com.synonym.paykit.PaymentEndpointSource import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange -import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -118,9 +117,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus( publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, liveSessionAvailable = true, - privateLinkCapable = true, ), ) whenever(walletRepo.walletExists()).thenReturn(true) @@ -708,14 +705,12 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test { + fun `beginSavedContactPayment uses public SDK endpoint when live session is unavailable`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus( publicKey = OWN_KEY, - capability = PubkyIdentityCapability.PUBLIC_ONLY, - liveSessionAvailable = true, - privateLinkCapable = false, + liveSessionAvailable = false, ), ) sut.prepareSavedContacts(listOf(CONTACT_KEY)) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index c809c7d7e5..194935d0b7 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -4,7 +4,11 @@ import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test +import to.bitkit.data.keychain.Keychain +import to.bitkit.utils.AppError +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class PaykitSdkServiceTest { @Test @@ -13,4 +17,47 @@ class PaykitSdkServiceTest { assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) } + + @Test + fun `receiver noise key is generated persisted and reused`() { + var persistedBytes: ByteArray? = null + val store = keyStore( + loadBytes = { persistedBytes }, + upsertBytes = { persistedBytes = it.copyOf() }, + ) + + val first = store.loadOrCreateBytes { ByteArray(32) { 7 } } + val second = store.loadOrCreateBytes { error("Existing key must be reused") } + val restored = keyStore(loadBytes = { persistedBytes }) + .loadOrCreateBytes { error("Persisted key must be reused") } + + assertEquals(32, first.size) + assertContentEquals(first, persistedBytes) + assertContentEquals(first, second) + assertContentEquals(first, restored) + assertEquals("PAYKIT_RECEIVER_NOISE_SECRET_KEY", Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name) + } + + @Test + fun `receiver noise key cannot be replaced`() { + val persistedBytes = ByteArray(32) { 1 } + val store = keyStore(loadBytes = { persistedBytes }) + + assertFailsWith { + store.persistBytes(ByteArray(32) { 2 }) + } + assertContentEquals(ByteArray(32) { 1 }, persistedBytes) + } + + @Test + fun `invalid persisted receiver noise key fails closed`() { + val store = keyStore(loadBytes = { ByteArray(31) }) + + assertFailsWith { store.loadOrCreateBytes { ByteArray(32) } } + } + + private fun keyStore( + loadBytes: () -> ByteArray?, + upsertBytes: (ByteArray) -> Unit = {}, + ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 596a7e38e9..184816d8ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ 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.2" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc36" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc37" } 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" } From 5050c1e6cead34a2d21160217add796b7ef6b7d4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 13:41:50 +0200 Subject: [PATCH 13/13] fix: derive Paykit Noise key from wallet seed --- .../to/bitkit/services/PaykitSdkService.kt | 109 +++++++++++++----- .../bitkit/services/PaykitSdkServiceTest.kt | 57 +++++++-- 2 files changed, 130 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 32284da951..25ea826aa3 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.content.Context +import com.synonym.bitkitcore.mnemonicToSeed import com.synonym.paykit.ContactPaymentResolution import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.ContactProfileResolution @@ -69,7 +70,10 @@ import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.security.MessageDigest import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton @@ -131,7 +135,6 @@ class PaykitSdkService @Inject constructor( try { PaykitAndroid.initializeOrThrow(context) operationMutex.withLock { - sessionProvider.loadOrCreateReceiverNoiseSecretKey() val handle = handle() handle.initialize() publishReceiverMarkerIfLiveSessionAvailable(handle) @@ -167,7 +170,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() + val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey() val result = PubkySessionBootstrap().importSession( sessionSecret = secret, localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, @@ -192,7 +195,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() + val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey() val result = PubkySessionBootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = receiverNoiseSecretKey, @@ -214,7 +217,7 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey() + val receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey() val result = PubkySessionBootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = receiverNoiseSecretKey, @@ -249,7 +252,7 @@ class PaykitSdkService @Inject constructor( try { request.complete( localSecretKey = null, - receiverNoiseSecretKey = sessionProvider.loadOrCreateReceiverNoiseSecretKey(), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), ).also { activateBootstrapResult( @@ -861,7 +864,7 @@ private class PaykitSdkSessionProvider( return PubkySessionAccess( sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrCreateReceiverNoiseSecretKey(), + receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), ) } @@ -882,17 +885,55 @@ private class PaykitSdkSessionProvider( return PaykitSdkService.localSecretKey(secretKeyHex) } - fun loadOrCreateReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = - receiverNoiseKeyStore.loadOrCreate() + fun loadOrDeriveReceiverNoiseSecretKey(): ReceiverNoiseSecretKey = + receiverNoiseKeyStore.loadOrDerive() fun persistReceiverNoiseSecretKey(key: ReceiverNoiseSecretKey) { receiverNoiseKeyStore.persist(key) } } +internal object PaykitReceiverNoiseKeyDerivation { + private const val DOMAIN = "bitkit/paykit/receiver-noise-key" + private const val VERSION = "v1" + + fun deriveFromWalletSeed( + mnemonic: String, + passphrase: String?, + network: String, + receiverPath: String, + ): ByteArray { + val seed = mnemonicToSeed(mnemonic, passphrase?.takeIf { it.isNotEmpty() }) + return try { + derive(seed, network, receiverPath) + } finally { + seed.fill(0) + } + } + + fun derive(seed: ByteArray, network: String, receiverPath: String): ByteArray { + val salt = MessageDigest.getInstance("SHA-256").digest(DOMAIN.encodeToByteArray()) + val prk = hmacSha256(key = salt, data = seed) + return try { + val info = "$VERSION\u0000$network\u0000$receiverPath".encodeToByteArray() + byteArrayOf(0x01) + hmacSha256(key = prk, data = info) + } finally { + prk.fill(0) + } + } + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + } +} + internal class PaykitReceiverNoiseKeyStore( private val loadBytes: () -> ByteArray?, private val upsertBytes: (ByteArray) -> Unit, + private val deriveBytes: () -> ByteArray, ) { constructor(keychain: Keychain) : this( loadBytes = { @@ -905,12 +946,23 @@ internal class PaykitReceiverNoiseKeyStore( upsert(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name, bytes) } }, + deriveBytes = { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw AppError("Mnemonic not found while deriving the Paykit receiver Noise key") + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) + PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic = mnemonic, + passphrase = passphrase, + network = Env.network.name.lowercase(), + receiverPath = PaykitReceiverPaths.WALLET, + ) + }, ) + private var validatedBytes: ByteArray? = null @Synchronized - fun loadOrCreate(): ReceiverNoiseSecretKey { - val bytes = loadOrCreateBytes { ReceiverNoiseSecretKey.random().exportBytes() } - return ReceiverNoiseSecretKey(bytes) + fun loadOrDerive(): ReceiverNoiseSecretKey { + return ReceiverNoiseSecretKey(validatedKeyBytes().copyOf()) } @Synchronized @@ -919,28 +971,31 @@ internal class PaykitReceiverNoiseKeyStore( } @Synchronized - internal fun loadOrCreateBytes(generateBytes: () -> ByteArray): ByteArray { - loadBytes()?.let { storedBytes -> - checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid") - return storedBytes - } - - val bytes = generateBytes() - checkKeyLength(bytes, "Generated Paykit receiver Noise key is invalid") - upsertBytes(bytes) - return bytes + internal fun loadOrDeriveBytes(): ByteArray { + return validatedKeyBytes().copyOf() } @Synchronized internal fun persistBytes(bytes: ByteArray) { - checkKeyLength(bytes, "Paykit returned an invalid receiver Noise key") + if (!validatedKeyBytes().contentEquals(bytes)) { + throw AppError("Paykit receiver Noise key changed unexpectedly") + } + } + + private fun validatedKeyBytes(): ByteArray { + validatedBytes?.let { return it } + + val derivedBytes = deriveBytes() + checkKeyLength(derivedBytes, "Derived Paykit receiver Noise key is invalid") loadBytes()?.let { storedBytes -> - if (!storedBytes.contentEquals(bytes)) { - throw AppError("Paykit receiver Noise key changed unexpectedly") + checkKeyLength(storedBytes, "Stored Paykit receiver Noise key is invalid") + if (!storedBytes.contentEquals(derivedBytes)) { + throw AppError("Stored Paykit receiver Noise key does not match the wallet seed") } - return - } - upsertBytes(bytes) + } ?: upsertBytes(derivedBytes.copyOf()) + + validatedBytes = derivedBytes.copyOf() + return derivedBytes } private fun checkKeyLength(bytes: ByteArray, message: String) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 194935d0b7..4e4e8aa325 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -5,6 +5,8 @@ import com.synonym.paykit.EndpointManagementScope import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex import to.bitkit.utils.AppError import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -19,17 +21,37 @@ class PaykitSdkServiceTest { } @Test - fun `receiver noise key is generated persisted and reused`() { + fun `receiver noise derivation matches versioned cross platform vector`() { + val seed = ( + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + + "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ).fromHex() + + val key = PaykitReceiverNoiseKeyDerivation.derive( + seed = seed, + network = "bitcoin", + receiverPath = "bitkit/wallet", + ) + + assertEquals("500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327", key.toHex()) + } + + @Test + fun `receiver noise key is derived persisted and reused`() { var persistedBytes: ByteArray? = null + val derivedBytes = ByteArray(32) { 7 } val store = keyStore( loadBytes = { persistedBytes }, upsertBytes = { persistedBytes = it.copyOf() }, + deriveBytes = { derivedBytes }, ) - val first = store.loadOrCreateBytes { ByteArray(32) { 7 } } - val second = store.loadOrCreateBytes { error("Existing key must be reused") } - val restored = keyStore(loadBytes = { persistedBytes }) - .loadOrCreateBytes { error("Persisted key must be reused") } + val first = store.loadOrDeriveBytes() + val second = store.loadOrDeriveBytes() + val restored = keyStore( + loadBytes = { persistedBytes }, + deriveBytes = { derivedBytes }, + ).loadOrDeriveBytes() assertEquals(32, first.size) assertContentEquals(first, persistedBytes) @@ -41,7 +63,10 @@ class PaykitSdkServiceTest { @Test fun `receiver noise key cannot be replaced`() { val persistedBytes = ByteArray(32) { 1 } - val store = keyStore(loadBytes = { persistedBytes }) + val store = keyStore( + loadBytes = { persistedBytes }, + deriveBytes = { ByteArray(32) { 1 } }, + ) assertFailsWith { store.persistBytes(ByteArray(32) { 2 }) @@ -51,13 +76,27 @@ class PaykitSdkServiceTest { @Test fun `invalid persisted receiver noise key fails closed`() { - val store = keyStore(loadBytes = { ByteArray(31) }) + val store = keyStore( + loadBytes = { ByteArray(31) }, + deriveBytes = { ByteArray(32) }, + ) + + assertFailsWith { store.loadOrDeriveBytes() } + } + + @Test + fun `cached receiver noise key from another wallet seed fails closed`() { + val store = keyStore( + loadBytes = { ByteArray(32) { 1 } }, + deriveBytes = { ByteArray(32) { 2 } }, + ) - assertFailsWith { store.loadOrCreateBytes { ByteArray(32) } } + assertFailsWith { store.loadOrDeriveBytes() } } private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, - ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes) + deriveBytes: () -> ByteArray, + ) = PaykitReceiverNoiseKeyStore(loadBytes, upsertBytes, deriveBytes) }