From 6624dd304b194017060d835d384e7bd3d25c1b93 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 11:33:34 +0300 Subject: [PATCH 1/4] refactor: integrate paykit sdk --- .../to/bitkit/data/PrivatePaykitStores.kt | 11 +- .../main/java/to/bitkit/data/PubkyStore.kt | 2 + .../java/to/bitkit/data/keychain/Keychain.kt | 26 +- app/src/main/java/to/bitkit/env/Env.kt | 24 - .../java/to/bitkit/models/BackupPayloads.kt | 17 +- .../java/to/bitkit/models/PubkyProfile.kt | 41 +- .../to/bitkit/models/PubkyPublicKeyFormat.kt | 14 +- .../java/to/bitkit/repositories/BackupRepo.kt | 89 +- .../PrivatePaykitErrorClassifier.kt | 54 - .../repositories/PrivatePaykitModels.kt | 115 +- .../repositories/PrivatePaykitPayloads.kt | 77 - .../PrivatePaykitRecoveryStore.kt | 234 --- .../bitkit/repositories/PrivatePaykitRepo.kt | 1824 ++++------------- .../repositories/PrivatePaykitStateStore.kt | 75 - .../java/to/bitkit/repositories/PubkyRepo.kt | 361 ++-- .../bitkit/repositories/PublicPaykitRepo.kt | 37 +- .../to/bitkit/services/PaykitSdkService.kt | 709 +++++++ .../java/to/bitkit/services/PubkyService.kt | 290 +-- .../screens/profile/EditProfileViewModel.kt | 87 +- .../ui/screens/profile/ProfileViewModel.kt | 40 +- .../ui/screens/settings/DevSettingsScreen.kt | 3 +- .../to/bitkit/usecases/WipeWalletUseCase.kt | 6 +- .../bitkit/models/PubkyPublicKeyFormatTest.kt | 14 +- .../to/bitkit/repositories/BackupRepoTest.kt | 23 +- ...PrivatePaykitAddressReservationRepoTest.kt | 4 +- .../PrivatePaykitContactResolverTest.kt | 2 +- .../repositories/PrivatePaykitRepoTest.kt | 1544 +++----------- .../to/bitkit/repositories/PubkyRepoTest.kt | 477 ++--- .../repositories/PublicPaykitRepoTest.kt | 407 +--- .../bitkit/services/PaykitSdkServiceTest.kt | 16 + .../screens/contacts/ContactImportFlowTest.kt | 2 +- .../profile/EditProfileViewModelTest.kt | 58 +- .../profile/PayContactsViewModelTest.kt | 2 +- .../screens/profile/ProfileViewModelTest.kt | 27 +- .../PaymentPreferenceViewModelTest.kt | 2 +- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 6 +- .../viewmodels/AppViewModelSendFlowTest.kt | 2 +- .../viewmodels/PubkyRouteResolverTest.kt | 4 +- .../viewmodels/SettingsViewModelTest.kt | 2 +- gradle/libs.versions.toml | 2 +- 40 files changed, 2188 insertions(+), 4542 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt delete mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt delete mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt create mode 100644 app/src/main/java/to/bitkit/services/PaykitSdkService.kt create mode 100644 app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index ad4875b250..dd810b0972 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -63,7 +63,6 @@ data class PrivatePaykitCacheData( val contacts: Map = emptyMap(), val cleanupPending: Boolean = false, val deletedContactCleanupPendingPublicKeys: Set = emptySet(), - val profileRecoveryPending: Boolean = false, ) @Serializable @@ -71,15 +70,7 @@ data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), val localInvoice: PrivatePaykitStoredInvoiceData? = null, val receivedInvoicePaymentHashes: List = emptyList(), - val lastLocalPayloadHash: String? = null, - val linkCompletedAt: Long? = null, - val handshakeUpdatedAt: Long? = null, - val recoveryStartedAt: Long? = null, - val mainRecoveryAttemptId: String? = null, - val responderRecoveryAttemptId: String? = null, - val lastCompletedRecoveryAttemptId: String? = null, - val awaitingRecoveredRemoteEndpoints: Boolean = false, - val linkFailureCount: Int = 0, + val hasPublishedPrivatePaymentList: Boolean = false, ) @Serializable diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 98d9719645..3dcc132236 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import to.bitkit.data.serializers.PubkyStoreSerializer +import to.bitkit.models.PubkyProfileData import javax.inject.Inject import javax.inject.Singleton @@ -36,4 +37,5 @@ class PubkyStore @Inject constructor( data class PubkyStoreData( val cachedName: String? = null, val cachedImageUri: String? = null, + val contactProfileOverrides: Map = emptyMap(), ) 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 5c2e0ceedb..86a40f0472 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -80,10 +80,9 @@ class Keychain @Inject constructor( } catch (t: Throwable) { throw KeychainError.FailedToSave(key, cause = t) } - Logger.info("Saved to keychain: $key") + Logger.info("Saved value for key '$key'", context = TAG) } - /** Inserts or replaces a string value associated with a given key in the keychain. */ @Suppress("TooGenericExceptionCaught") suspend fun upsertString(key: String, value: String) { try { @@ -94,7 +93,22 @@ class Keychain @Inject constructor( } catch (t: Throwable) { throw KeychainError.FailedToSave(key, cause = t) } - Logger.info("Upsert in keychain: $key") + Logger.info("Upserted value for key '$key'", context = TAG) + } + + @Suppress("TooGenericExceptionCaught") + fun upsert(key: String, value: ByteArray) { + try { + val encryptedValue = keyStore.encrypt(value) + runBlocking(this.coroutineContext) { + keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + } + } catch (c: CancellationException) { + throw c + } catch (t: Throwable) { + throw KeychainError.FailedToSave(key, cause = t) + } + Logger.info("Upserted value for key '$key'", context = TAG) } @Suppress("TooGenericExceptionCaught") @@ -106,7 +120,7 @@ class Keychain @Inject constructor( } catch (t: Throwable) { throw KeychainError.FailedToDelete(key, cause = t) } - Logger.debug("Deleted from keychain: $key") + Logger.debug("Deleted value for key '$key'", context = TAG) } fun exists(key: String): Boolean { @@ -119,7 +133,7 @@ class Keychain @Inject constructor( keyStore.resetEncryptionKey() val count = keys.size - Logger.info("Reset keychain encryption key and deleted all '$count' entries") + Logger.info("Reset keychain encryption key and deleted all '$count' entries", context = TAG) } private val String.indexed: Preferences.Key @@ -174,7 +188,7 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, - PRIVATE_PAYKIT_SECRET_STATE, + PAYKIT_SDK_STATE, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 5478a48454..8567241718 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -158,21 +158,6 @@ internal object Env { const val BITREFILL_APP = "Bitkit" const val BITREFILL_REF = "AL6dyZYt" - private val pubkyDomain: String - get() = when (network) { - Network.BITCOIN -> "bitkit.to" - else -> "staging.bitkit.to" - } - - val pubkyCapabilities: String - get() { - val prefix = when (network) { - Network.BITCOIN -> "" - else -> "staging." - } - return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/paykit/v0/:rw" - } - val homegateUrl: String get() { if (isLocalE2eBackend) { @@ -185,15 +170,6 @@ internal object Env { } } - val profilePath: String - get() = "/pub/$pubkyDomain/profile.json" - - val contactsBasePath: String - get() = "/pub/$pubkyDomain/contacts/" - - val blobsBasePath: String - get() = "/pub/$pubkyDomain/blobs/" - val rnBackupServerHost: String get() = when (network) { Network.BITCOIN -> "https://blocktank.synonym.to/backups-ldk" diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 26002660fb..f08ae0394f 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -20,21 +20,7 @@ data class WalletBackupV1( val createdAt: Long, val transfers: List, val privatePaykitHighestReservedReceiveIndexByAddressType: Map? = null, - val privatePaykitContactLinks: Map? = null, -) - -@Serializable -data class PrivatePaykitContactLinkBackupV1( - val publicKey: String, - val linkSnapshotHex: String? = null, - val handshakeSnapshotHex: String? = null, - val remoteEndpoints: Map = emptyMap(), - val linkCompletedAt: Long? = null, - val handshakeUpdatedAt: Long? = null, - val recoveryStartedAt: Long? = null, - val mainRecoveryAttemptId: String? = null, - val responderRecoveryAttemptId: String? = null, - val awaitingRecoveredRemoteEndpoints: Boolean = false, + val paykitSdkBackupState: String? = null, ) @Serializable @@ -44,6 +30,7 @@ data class MetadataBackupV1( val tagMetadata: List, val cache: AppCacheData, val pubkySession: PubkySessionBackupV1? = null, + val pubkyContactProfileOverrides: Map? = null, ) @Serializable diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index db97382188..9b423eb2b9 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -5,7 +5,10 @@ import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import to.bitkit.ext.ellipsisMiddle -import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile +import com.synonym.paykit.PaykitProfile as SdkPaykitProfile +import com.synonym.paykit.PubkyProfile as SdkPubkyProfile + +private val pubkyProfileJson = Json { ignoreUnknownKeys = true } @Immutable data class PubkyProfileLink(val label: String, val url: String) @@ -23,18 +26,21 @@ data class PubkyProfile( companion object { private const val TRUNCATED_PK_LENGTH = 11 - fun fromFfi(publicKey: String, ffiProfile: CorePubkyProfile): PubkyProfile { + fun fromPubkyProfile(publicKey: String, sdkProfile: SdkPubkyProfile): PubkyProfile { return PubkyProfile( publicKey = publicKey, - name = ffiProfile.name, - bio = ffiProfile.bio ?: "", - imageUrl = ffiProfile.image, - links = ffiProfile.links.orEmpty().map { PubkyProfileLink(label = it.title, url = it.url) }, + name = sdkProfile.name, + bio = sdkProfile.bio ?: "", + imageUrl = sdkProfile.image, + links = sdkProfile.links.map { PubkyProfileLink(label = it.title, url = it.url) }, tags = emptyList(), - status = ffiProfile.status, + status = sdkProfile.status, ) } + fun fromPaykitProfile(publicKey: String, profile: SdkPaykitProfile): PubkyProfile = + PubkyProfileData.fromPaykitProfile(profile).toPubkyProfile(publicKey) + fun placeholder(publicKey: String) = PubkyProfile( publicKey = publicKey, name = publicKey.ellipsisMiddle(TRUNCATED_PK_LENGTH), @@ -85,11 +91,28 @@ data class PubkyProfileData( ) { companion object { fun decode(json: String): PubkyProfileData = - Json { ignoreUnknownKeys = true }.decodeFromString(json) + pubkyProfileJson.decodeFromString(json) + + fun fromPaykitProfile(profile: SdkPaykitProfile): PubkyProfileData { + val extra = profile.extraJson?.let { runCatching { decode(it) }.getOrNull() } + return PubkyProfileData( + name = profile.displayName ?: extra?.name.orEmpty(), + bio = extra?.bio.orEmpty(), + image = profile.imageUri ?: extra?.image, + links = extra?.links.orEmpty(), + tags = extra?.tags.orEmpty(), + ) + } } fun encode(): ByteArray = - Json.encodeToString(this).toByteArray(Charsets.UTF_8) + pubkyProfileJson.encodeToString(this).toByteArray(Charsets.UTF_8) + + fun toPaykitProfile() = SdkPaykitProfile( + displayName = name, + imageUri = image, + extraJson = encode().toString(Charsets.UTF_8), + ) fun toPubkyProfile(publicKey: String) = PubkyProfile( publicKey = publicKey, diff --git a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt index 357809a533..069552f88f 100644 --- a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt +++ b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt @@ -1,16 +1,13 @@ package to.bitkit.models +import com.synonym.paykit.PaykitPublicKeys import to.bitkit.ext.ellipsisMiddle import java.util.Locale object PubkyPublicKeyFormat { - private const val pubkyPrefix = "pubky" - private const val rawKeyLength = 52 private const val redactedLength = 16 const val maximumInputLength = 57 - private val zBase32Regex = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$") - fun bounded(input: String): String { return input .trim() @@ -19,14 +16,7 @@ object PubkyPublicKeyFormat { } fun normalized(input: String): String? { - val normalizedInput = input.trim().lowercase(Locale.US) - val rawKey = normalizedInput.removePrefix(pubkyPrefix) - - if (rawKey.length != rawKeyLength || !zBase32Regex.matches(rawKey)) { - return null - } - - return "$pubkyPrefix$rawKey" + return runCatching { PaykitPublicKeys.normalize(bounded(input)) }.getOrNull() } fun matches(lhs: String?, rhs: String?): Boolean { diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index efa850da5f..57b5681405 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +48,7 @@ import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WidgetsBackupV1 import to.bitkit.services.LightningService +import to.bitkit.services.PaykitSdkService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf @@ -86,6 +88,7 @@ class BackupRepo @Inject constructor( private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val pubkyRepo: PubkyRepo, + private val paykitSdkService: PaykitSdkService, private val privatePaykitRepo: Provider, private val privatePaykitAddressReservationRepo: Provider, private val preActivityMetadataRepo: PreActivityMetadataRepo, @@ -278,57 +281,21 @@ class BackupRepo @Inject constructor( } dataListenerJobs.add(preActivityMetadataJob) - val pubkyStateJob = scope.launch { - pubkyRepo.backupStateVersion - .drop(1) - .collect { - if (shouldSkipBackup()) return@collect - markBackupRequired(BackupCategory.METADATA) - } - } - dataListenerJobs.add(pubkyStateJob) - - val privatePaykitStateJob = scope.launch { - privatePaykitRepo.get().backupStateVersion - .drop(1) - .collect { - if (shouldSkipBackup()) return@collect - markBackupRequired(BackupCategory.WALLET) - } - } - dataListenerJobs.add(privatePaykitStateJob) - - val privatePaykitReservationJob = scope.launch { - privatePaykitAddressReservationRepo.get().backupStateVersion - .drop(1) - .collect { - if (shouldSkipBackup()) return@collect - markBackupRequired(BackupCategory.WALLET) - } - } - dataListenerJobs.add(privatePaykitReservationJob) + dataListenerJobs.add(observeBackupChanges(pubkyRepo.backupStateVersion, BackupCategory.METADATA)) + dataListenerJobs.add(observeBackupChanges(privatePaykitRepo.get().backupStateVersion, BackupCategory.WALLET)) + dataListenerJobs.add(observeBackupChanges(paykitSdkService.backupStateVersion, BackupCategory.WALLET)) + dataListenerJobs.add( + observeBackupChanges( + privatePaykitAddressReservationRepo.get().backupStateVersion, + BackupCategory.WALLET, + ) + ) // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) - val blocktankJob = scope.launch { - blocktankRepo.blocktankState - .drop(1) - .collect { - if (shouldSkipBackup()) return@collect - markBackupRequired(BackupCategory.BLOCKTANK) - } - } - dataListenerJobs.add(blocktankJob) + dataListenerJobs.add(observeBackupChanges(blocktankRepo.blocktankState, BackupCategory.BLOCKTANK)) // ACTIVITY - Observe activity changes - val activityChangesJob = scope.launch { - activityRepo.activitiesChanged - .drop(1) - .collect { - if (shouldSkipBackup()) return@collect - markBackupRequired(BackupCategory.ACTIVITY) - } - } - dataListenerJobs.add(activityChangesJob) + dataListenerJobs.add(observeBackupChanges(activityRepo.activitiesChanged, BackupCategory.ACTIVITY)) // LIGHTNING_CONNECTIONS - Only display sync timestamp, ldk-node manages its own backups @OptIn(FlowPreview::class) @@ -350,6 +317,14 @@ class BackupRepo @Inject constructor( Logger.debug("Started ${dataListenerJobs.size} data store listeners", context = TAG) } + private fun observeBackupChanges(flow: Flow<*>, category: BackupCategory): Job = + scope.launch { + flow.drop(1).collect { + if (shouldSkipBackup()) return@collect + markBackupRequired(category) + } + } + private fun startPeriodicBackupFailureCheck() { periodicCheckJob = scope.launch { while (currentCoroutineContext().isActive) { @@ -538,12 +513,14 @@ class BackupRepo @Inject constructor( val preActivityMetadata = preActivityMetadataRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val cacheData = cacheStore.data.first() val pubkySession = pubkyRepo.snapshotSessionBackupState().getOrDefault(null) + val pubkyContactProfileOverrides = pubkyRepo.snapshotContactProfileOverrides().getOrDefault(null) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), tagMetadata = preActivityMetadata, cache = cacheData, pubkySession = pubkySession, + pubkyContactProfileOverrides = pubkyContactProfileOverrides, ) json.encodeToString(payload).toByteArray() @@ -556,9 +533,9 @@ class BackupRepo @Inject constructor( Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG) } .getOrThrow() - val privateLinks = privatePaykitRepo.get().backupSnapshot() + val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot() .onFailure { - Logger.warn("Failed to snapshot private Paykit contact links", it, context = TAG) + Logger.warn("Failed to snapshot Paykit SDK state", it, context = TAG) } .getOrThrow() @@ -566,7 +543,7 @@ class BackupRepo @Inject constructor( createdAt = currentTimeMillis(), transfers = transfers, privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, - privatePaykitContactLinks = privateLinks, + paykitSdkBackupState = paykitSdkBackupState, ) return json.encodeToString(payload).toByteArray() @@ -591,6 +568,10 @@ class BackupRepo @Inject constructor( .onFailure { Logger.warn("Failed to restore pubky session backup state", it, context = TAG) } + pubkyRepo.restoreContactProfileOverrides(parsed.pubkyContactProfileOverrides) + .onFailure { + Logger.warn("Failed to restore pubky contact profile overrides", it, context = TAG) + } Logger.debug("Restored ${parsed.tagMetadata.size} pre-activity metadata", TAG) parsed.createdAt } @@ -639,7 +620,13 @@ class BackupRepo @Inject constructor( val addressReservationRepo = privatePaykitAddressReservationRepo.get() addressReservationRepo.restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType).getOrThrow() val privateRepo = privatePaykitRepo.get() - privateRepo.restoreBackup(parsed.privatePaykitContactLinks).getOrThrow() + if (parsed.paykitSdkBackupState != null) { + privateRepo.restoreBackup(parsed.paykitSdkBackupState).getOrThrow() + } else { + privateRepo.restoreBackup(null).onFailure { + Logger.warn("Failed to clear missing Paykit SDK backup state", it, context = TAG) + } + } addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) return parsed.createdAt diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt index 9dd68941ec..f1453dcabc 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt @@ -1,6 +1,5 @@ package to.bitkit.repositories -import com.synonym.paykit.PaykitFfiException import org.lightningdevkit.ldknode.NodeException internal object PrivatePaykitErrorClassifier { @@ -14,58 +13,5 @@ internal object PrivatePaykitErrorClassifier { return "duplicate payment" in reason || "duplicatepayment" in reason } - fun shouldCountAsStaleLinkFailure(error: Throwable): Boolean { - val errors = error.causes() - if (errors.any { it is PaykitFfiException.Session }) return false - - return errors.flatMap { it.staleLinkFailureReasons() } - .any { isNoiseStateFailure(it) || isEncryptedLinkStateFailure(it) } - } - - fun shouldRetryLinkEstablishmentFailure(error: Throwable): Boolean = - error.causes().none { - it is PrivatePaykitError.PrivateUnavailable || it is PrivatePaykitError.StaleLinkState - } - - fun isEncryptedHandshakeStateFailure(error: Throwable): Boolean { - val reason = error.message.orEmpty().lowercase() - return isNoiseStateFailure(reason) || - isEncryptedLinkStateFailure(reason) || - listOf("restoreplayerror", "handshake restore failed").any { it in reason } - } - - fun isEncryptedHandshakePendingError(error: Throwable): Boolean { - val reason = error.message.orEmpty().lowercase() - return "transition_transport failed" in reason && "ishandshake" in reason - } - private fun Throwable.causes(): List = generateSequence(this) { it.cause }.toList() - - private fun Throwable.staleLinkFailureReasons(): List = when (this) { - is PaykitFfiException.Transport -> listOf(reason) - is PaykitFfiException.InvalidData -> listOf(reason) - is PaykitFfiException.NotFound -> listOf(reason) - is PaykitFfiException.Validation -> listOf(reason) - is PaykitFfiException.Session -> emptyList() - else -> listOfNotNull(message) - } - - private fun isNoiseStateFailure(reason: String): Boolean { - val lowercasedReason = reason.lowercase() - return listOf("decrypt", "decryption", "cipher", "invalid tag", "bad mac") - .any { it in lowercasedReason } - } - - private fun isEncryptedLinkStateFailure(reason: String): Boolean { - val lowercasedReason = reason.lowercase() - return listOf( - "unknown encrypted-link handle", - "unknown encrypted link handle", - "encrypted-link handle is closed", - "encrypted link handle is closed", - "failed to restore encrypted link", - "encrypted link restore requires transport-phase snapshot", - "remote_pubkey does not match snapshot recipient", - ).any { it in lowercasedReason } - } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index fc8bd22f8b..ff3b42719c 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -1,146 +1,62 @@ package to.bitkit.repositories -import kotlinx.serialization.Serializable import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData import to.bitkit.data.PrivatePaykitStoredPaymentEntryData import to.bitkit.utils.AppError -sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) { +sealed class PrivatePaykitError(message: String) : AppError(message) { data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") - data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") data object RouteHintsUnavailable : PrivatePaykitError("Reachable private Lightning endpoint is not available yet") - data object SnapshotRecipientMismatch : PrivatePaykitError("Private Paykit snapshot recipient mismatch") - data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed") - class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause) } -internal data class ContactPaykitHandles( - val linkId: String? = null, - val handshakeId: String? = null, -) - internal data class PrivatePaykitState( val contacts: MutableMap = mutableMapOf(), ) { - constructor(secretState: PrivatePaykitSecretState, cacheState: PrivatePaykitCacheData) : this( + constructor(cacheState: PrivatePaykitCacheData) : this( contacts = cacheState.contacts.mapValues { (_, cache) -> ContactState(cache) }.toMutableMap(), - ) { - secretState.contacts.forEach { (publicKey, secret) -> - val contactState = contacts.getOrPut(publicKey) { ContactState() } - contactState.linkSnapshotHex = secret.linkSnapshotHex - contactState.handshakeSnapshotHex = secret.handshakeSnapshotHex - } - } - - fun secretState() = PrivatePaykitSecretState( - contacts = contacts.mapNotNull { (publicKey, contactState) -> - val secretState = ContactSecretState(contactState.linkSnapshotHex, contactState.handshakeSnapshotHex) - (publicKey to secretState).takeIf { secretState.hasSecretState } - }.toMap(), ) fun cacheState( cleanupPending: Boolean, deletedContactCleanupPendingPublicKeys: Set, - profileRecoveryPending: Boolean, ) = PrivatePaykitCacheData( contacts = contacts.mapNotNull { (publicKey, contactState) -> (publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState } }.toMap(), cleanupPending = cleanupPending, deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys, - profileRecoveryPending = profileRecoveryPending, ) } internal data class ContactState( - var linkSnapshotHex: String? = null, - var handshakeSnapshotHex: String? = null, var remoteEndpoints: List = emptyList(), var localInvoice: StoredInvoice? = null, var receivedInvoicePaymentHashes: List = emptyList(), - var lastLocalPayloadHash: String? = null, - var linkCompletedAt: Long? = null, - var handshakeUpdatedAt: Long? = null, - var recoveryStartedAt: Long? = null, - var mainRecoveryAttemptId: String? = null, - var responderRecoveryAttemptId: String? = null, - var lastCompletedRecoveryAttemptId: String? = null, - var awaitingRecoveredRemoteEndpoints: Boolean = false, - var linkFailureCount: Int = 0, + var hasPublishedPrivatePaymentList: Boolean = false, ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, localInvoice = cache.localInvoice?.let { StoredInvoice(it.bolt11, it.paymentHash, it.expiresAt) }, receivedInvoicePaymentHashes = cache.receivedInvoicePaymentHashes, - lastLocalPayloadHash = cache.lastLocalPayloadHash, - linkCompletedAt = cache.linkCompletedAt, - handshakeUpdatedAt = cache.handshakeUpdatedAt, - recoveryStartedAt = cache.recoveryStartedAt, - mainRecoveryAttemptId = cache.mainRecoveryAttemptId, - responderRecoveryAttemptId = cache.responderRecoveryAttemptId, - lastCompletedRecoveryAttemptId = cache.lastCompletedRecoveryAttemptId, - awaitingRecoveredRemoteEndpoints = cache.awaitingRecoveredRemoteEndpoints, - linkFailureCount = cache.linkFailureCount, + hasPublishedPrivatePaymentList = cache.hasPublishedPrivatePaymentList, ) - val hasBackupState: Boolean - get() = linkSnapshotHex != null || - handshakeSnapshotHex != null || - remoteEndpoints.isNotEmpty() || - linkCompletedAt != null || - handshakeUpdatedAt != null || - recoveryStartedAt != null || - mainRecoveryAttemptId != null || - responderRecoveryAttemptId != null || - lastCompletedRecoveryAttemptId != null - val hasCacheState: Boolean - get() = remoteEndpoints.isNotEmpty() || + get() = hasPublishedPrivatePaymentList || + remoteEndpoints.isNotEmpty() || localInvoice != null || - receivedInvoicePaymentHashes.isNotEmpty() || - lastLocalPayloadHash != null || - linkCompletedAt != null || - handshakeUpdatedAt != null || - recoveryStartedAt != null || - mainRecoveryAttemptId != null || - responderRecoveryAttemptId != null || - lastCompletedRecoveryAttemptId != null || - awaitingRecoveredRemoteEndpoints || - linkFailureCount != 0 + receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, localInvoice = localInvoice?.let { PrivatePaykitStoredInvoiceData(it.bolt11, it.paymentHash, it.expiresAt) }, receivedInvoicePaymentHashes = receivedInvoicePaymentHashes, - lastLocalPayloadHash = lastLocalPayloadHash, - linkCompletedAt = linkCompletedAt, - handshakeUpdatedAt = handshakeUpdatedAt, - recoveryStartedAt = recoveryStartedAt, - mainRecoveryAttemptId = mainRecoveryAttemptId, - responderRecoveryAttemptId = responderRecoveryAttemptId, - lastCompletedRecoveryAttemptId = lastCompletedRecoveryAttemptId, - awaitingRecoveredRemoteEndpoints = awaitingRecoveredRemoteEndpoints, - linkFailureCount = linkFailureCount, + hasPublishedPrivatePaymentList = hasPublishedPrivatePaymentList, ) } -@Serializable -internal data class PrivatePaykitSecretState( - val contacts: Map = emptyMap(), -) - -@Serializable -internal data class ContactSecretState( - val linkSnapshotHex: String? = null, - val handshakeSnapshotHex: String? = null, -) { - val hasSecretState: Boolean - get() = linkSnapshotHex != null || handshakeSnapshotHex != null -} - internal data class StoredPaymentEntry( val methodId: String, val endpointData: String, @@ -151,18 +67,3 @@ internal data class StoredInvoice( val paymentHash: String, val expiresAt: Long, ) - -internal data class PrivateStoragePurgeResult( - val deletedCount: Int, - val didHitLimit: Boolean, - val didFail: Boolean, -) - -@Serializable -internal data class RecoveryMarker( - val version: Int, - val path: String, - val stage: String, - val attemptId: String, - val createdAt: Long, -) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt deleted file mode 100644 index ec22395822..0000000000 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt +++ /dev/null @@ -1,77 +0,0 @@ -package to.bitkit.repositories - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import to.bitkit.di.json -import java.security.MessageDigest - -internal object PrivatePaykitPayloads { - private const val MAX_NOISE_PAYLOAD_BYTES = 1000 - private const val PRIVATE_ENDPOINT_REMOVAL_PAYLOAD = """{"value":""}""" - private const val PRIVATE_PAYMENTS_ENVELOPE_KIND = "paykit.private_payments" - private const val PRIVATE_PAYMENTS_REFERENCE_PLACEHOLDER = "550e8400-e29b-41d4-a716-446655440000" - - private val noisePayloadJson = Json(json) { - prettyPrint = false - } - - fun entriesWithinNoiseLimit(endpoints: List): PrivatePaykitPayloadSelection { - val entries = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } - if (isNoisePayloadWithinLimit(entries)) return PrivatePaykitPayloadSelection(entries) - - val onchainOnlyEntries = entries.filter { it.methodId != MethodId.Bolt11.rawValue } - if (onchainOnlyEntries.size < entries.size && onchainOnlyEntries.isNotEmpty()) { - if (isNoisePayloadWithinLimit(onchainOnlyEntries)) { - return PrivatePaykitPayloadSelection(entries = onchainOnlyEntries, droppedLightning = true) - } - } - - throw PrivatePaykitError.PayloadTooLarge - } - - fun privateEndpointRemovalEntries(): List = - MethodId.entries - .filter { it.isBitkitManaged } - .map { StoredPaymentEntry(it.rawValue, PRIVATE_ENDPOINT_REMOVAL_PAYLOAD) } - - fun validateNoisePayload(entries: List) { - if (!isNoisePayloadWithinLimit(entries)) throw PrivatePaykitError.PayloadTooLarge - } - - fun localPayloadHash(entries: List): String { - val payload = entries.sortedBy { it.methodId } - .joinToString(separator = "") { - "${it.methodId.length}:${it.methodId}${it.endpointData.length}:${it.endpointData}" - } - return MessageDigest.getInstance("SHA-256") - .digest(payload.encodeToByteArray()) - .joinToString(separator = "") { "%02x".format(it) } - } - - fun storedPaymentEntries(endpoints: Map): List = - endpoints.toSortedMap().map { StoredPaymentEntry(it.key, it.value) } - - private fun isNoisePayloadWithinLimit(entries: List): Boolean { - val payload = entries.associate { it.methodId to it.endpointData } - val envelope = PrivatePaymentsEnvelope( - version = 1, - kind = PRIVATE_PAYMENTS_ENVELOPE_KIND, - reference = PRIVATE_PAYMENTS_REFERENCE_PLACEHOLDER, - entries = payload, - ) - return noisePayloadJson.encodeToString(envelope).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES - } -} - -@kotlinx.serialization.Serializable -private data class PrivatePaymentsEnvelope( - val version: Int, - val kind: String, - val reference: String, - val entries: Map, -) - -internal data class PrivatePaykitPayloadSelection( - val entries: List, - val droppedLightning: Boolean = false, -) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt deleted file mode 100644 index 74ff1bad92..0000000000 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt +++ /dev/null @@ -1,234 +0,0 @@ -package to.bitkit.repositories - -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import to.bitkit.data.keychain.Keychain -import to.bitkit.di.json -import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.services.PubkyService -import to.bitkit.utils.Logger -import java.security.MessageDigest - -internal class PrivatePaykitRecoveryStore( - private val pubkyService: PubkyService, - private val keychain: Keychain, - private val stateProvider: suspend () -> PrivatePaykitState, -) { - companion object { - private const val TAG = "PrivatePaykitRecoveryStore" - private const val PRIVATE_STORAGE_ROOT_PATH = "/pub/paykit/v0/private/" - private const val PRIVATE_STORAGE_PURGE_MAX_ENTRIES = 500 - private const val PRIVATE_STORAGE_PURGE_MAX_DEPTH = 3 - } - - @Suppress("ReturnCount") - suspend fun freshRecoveryMarker( - from: String, - to: String, - stages: Set, - attemptId: String? = null, - ): RecoveryMarker? { - val markerUri = recoveryMarkerUri(from, to) ?: return null - val markerPath = recoveryMarkerPath(from, to) ?: return null - val marker = runCatching { - json.decodeFromString(pubkyService.fetchFileString(markerUri)) - }.getOrNull() ?: return null - - if (marker.version != 1) return null - if (marker.path != markerPath) return null - if (marker.stage !in stages) return null - if (marker.attemptId.isBlank()) return null - - val state = stateProvider() - val contactKey = listOf(from, to) - .mapNotNull { normalizedPublicKey(it) } - .firstOrNull { state.contacts[it] != null } - val linkCompletedAt = contactKey?.let { state.contacts[it]?.linkCompletedAt } ?: 0L - if (marker.createdAt <= linkCompletedAt) return null - if (attemptId != null && marker.attemptId != attemptId) return null - return marker - } - - suspend fun publishRecoveryMarker( - from: String, - to: String, - stage: String, - attemptId: String, - createdAt: Long, - ) { - val markerPath = recoveryMarkerPath(from, to) ?: return - val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) ?: return - if (sessionSecret.isBlank() || attemptId.isBlank()) return - - val marker = RecoveryMarker( - version = 1, - path = markerPath, - stage = stage, - attemptId = attemptId, - createdAt = createdAt, - ) - runCatching { - pubkyService.sessionPut(sessionSecret, markerPath, json.encodeToString(marker).encodeToByteArray()) - }.onFailure { - Logger.warn( - "Failed to publish private Paykit recovery marker for '${redacted(to)}'", - it, - context = TAG, - ) - } - } - - suspend fun clearRecoveryMarker(from: String, to: String) { - val markerPath = recoveryMarkerPath(from, to) ?: return - val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) ?: return - if (sessionSecret.isBlank()) return - runCatching { pubkyService.sessionDelete(sessionSecret, markerPath) } - } - - @Suppress("ReturnCount") - suspend fun purgePrivatePaymentOutbox(publicKey: String, reason: String): Boolean { - val otherContactCount = stateProvider().contacts.keys.count { it != publicKey } - if (otherContactCount > 0) { - Logger.warn( - "Skipping broad private Paykit transport cleanup during '$reason' because " + - "'$otherContactCount' other private contact(s) have state", - context = TAG, - ) - return true - } - - return purgePrivatePaymentStorage(reason) - } - - suspend fun purgePrivatePaymentOutboxForProfileRecovery(reason: String): Boolean = - purgePrivatePaymentStorage(reason) - - @Suppress("ReturnCount") - private suspend fun purgePrivatePaymentStorage(reason: String): Boolean { - val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) ?: return false - if (sessionSecret.isBlank()) return false - val rootPath = PRIVATE_STORAGE_ROOT_PATH.removeSuffix("/") - val deletedRoot = runCatching { - pubkyService.sessionDelete(sessionSecret, rootPath) - }.onSuccess { - Logger.info("Cleared stale private Paykit transport directory during '$reason'", context = TAG) - }.onFailure { - if (!isMissingPrivateStorageError(it)) { - Logger.warn("Failed to clear private Paykit transport directory during '$reason'", it, context = TAG) - } - }.isSuccess - if (deletedRoot) return true - - val purgeResult = runCatching { - purgePrivatePaymentStorageTree(sessionSecret, PRIVATE_STORAGE_ROOT_PATH, depth = 0, deletedSoFar = 0) - }.getOrElse { - if (!isMissingPrivateStorageError(it)) { - Logger.warn("Failed to purge private Paykit transport messages during '$reason'", it, context = TAG) - return false - } - return true - } - if (purgeResult.deletedCount > 0) { - Logger.info( - "Cleared '${purgeResult.deletedCount}' stale private Paykit transport messages during '$reason'", - context = TAG, - ) - } - if (purgeResult.didHitLimit) { - Logger.warn("Stopped private Paykit transport cleanup after reaching the safety limit", context = TAG) - } - return !purgeResult.didHitLimit && !purgeResult.didFail - } - - private suspend fun purgePrivatePaymentStorageTree( - sessionSecret: String, - dirPath: String, - depth: Int, - deletedSoFar: Int, - ): PrivateStoragePurgeResult { - if (deletedSoFar >= PRIVATE_STORAGE_PURGE_MAX_ENTRIES) { - return PrivateStoragePurgeResult(deletedCount = 0, didHitLimit = true, didFail = false) - } - if (depth >= PRIVATE_STORAGE_PURGE_MAX_DEPTH) { - return PrivateStoragePurgeResult(deletedCount = 0, didHitLimit = true, didFail = false) - } - - val entries = pubkyService.sessionList(sessionSecret, dirPath.withTrailingSlash()) - var deletedCount = 0 - var didHitLimit = false - var didFail = false - - entries.forEach { - if (deletedSoFar + deletedCount >= PRIVATE_STORAGE_PURGE_MAX_ENTRIES) { - didHitLimit = true - return@forEach - } - val path = privateStoragePath(it) ?: return@forEach - val deleted = runCatching { - pubkyService.sessionDelete(sessionSecret, path.removeSuffix("/")) - }.isSuccess - if (deleted) { - deletedCount += 1 - return@forEach - } - - val childResult = runCatching { - purgePrivatePaymentStorageTree( - sessionSecret = sessionSecret, - dirPath = path.withTrailingSlash(), - depth = depth + 1, - deletedSoFar = deletedSoFar + deletedCount, - ) - }.getOrElse { error -> - if (!isMissingPrivateStorageError(error)) didFail = true - return@forEach - } - deletedCount += childResult.deletedCount - didHitLimit = didHitLimit || childResult.didHitLimit - didFail = didFail || childResult.didFail - } - - return PrivateStoragePurgeResult( - deletedCount = deletedCount, - didHitLimit = didHitLimit, - didFail = didFail, - ) - } - - private fun privateStoragePath(entry: String): String? { - val path = if (entry.startsWith("pubky://")) { - "/${entry.substringAfter("://").substringAfter("/")}" - } else { - entry - } - val normalizedPath = if (path.startsWith("/")) path else "/$path" - return normalizedPath.takeIf { it.startsWith(PRIVATE_STORAGE_ROOT_PATH) } - } - - private fun recoveryMarkerPath(writerPublicKey: String, readerPublicKey: String): String? { - val writer = normalizedPublicKey(writerPublicKey) ?: return null - val reader = normalizedPublicKey(readerPublicKey) ?: return null - val material = "bitkit-private-paykit-recovery-v1|$writer|$reader" - val markerId = MessageDigest.getInstance("SHA-256") - .digest(material.encodeToByteArray()) - .joinToString(separator = "") { "%02x".format(it) } - return "/pub/paykit/v0/private-recovery/$markerId.json" - } - - private fun recoveryMarkerUri(writerPublicKey: String, readerPublicKey: String): String? { - val writer = normalizedPublicKey(writerPublicKey) ?: return null - val path = recoveryMarkerPath(writer, readerPublicKey) ?: return null - return "pubky://${writer.removePrefix("pubky")}$path" - } - - private fun isMissingPrivateStorageError(error: Throwable): Boolean { - val reason = error.message.orEmpty().lowercase() - return "404" in reason && "not found" in reason - } - - private fun String.withTrailingSlash(): String = if (endsWith("/")) this else "$this/" - - private fun normalizedPublicKey(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) - - private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) -} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index ff828e8e91..e4c55d08a2 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1,7 +1,11 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.PaymentEndpointReservationInput +import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,37 +27,30 @@ import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.App import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore -import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.toHex -import to.bitkit.models.PrivatePaykitContactLinkBackupV1 import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.utils.Logger -import java.util.UUID -import java.util.concurrent.atomic.AtomicLong +import java.security.MessageDigest +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -private data class PrivatePaymentAttempt( - val result: Result, - val shouldDeferPublicFallback: Boolean, -) - @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Singleton @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") class PrivatePaykitRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, private val pubkyService: PubkyService, - private val keychain: Keychain, private val cacheStore: PrivatePaykitCacheStore, private val settingsStore: SettingsStore, private val addressReservationRepo: PrivatePaykitAddressReservationRepo, @@ -66,39 +63,35 @@ class PrivatePaykitRepo @Inject constructor( companion object { private const val TAG = "PrivatePaykitRepo" private const val MAX_RECEIVED_INVOICE_HASHES_PER_CONTACT = 100 - private const val STALE_LINK_FAILURE_THRESHOLD = 3 - private const val HANDSHAKE_COMPLETE = "complete" - private const val RECOVERY_MARKER_STAGE_INIT = "init" - private const val RECOVERY_MARKER_STAGE_RESPONSE = "response" - private const val RECOVERY_MARKER_STAGE_FINAL = "final" - private const val FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS = 8L - private const val PENDING_PUBLICATION_RETRY_ATTEMPTS = 60 - private const val PRIVATE_PAYMENT_RECOVERY_RETRY_ATTEMPTS = 12 private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes - private val pendingPublicationRetryDelay = 5.seconds - private val privatePaymentRecoveryRetryDelay = 2.seconds - fun shouldInitiate(ownPublicKey: String, remotePublicKey: String): Boolean { - val own = PubkyPublicKeyFormat.normalized(ownPublicKey) ?: ownPublicKey - val remote = PubkyPublicKeyFormat.normalized(remotePublicKey) ?: remotePublicKey - return own > remote - } + // Private links can finish after a contact is added on the other device; + // keep draining long enough for staggered mutual adds. + private val privateMessageDrainRetryDelays = listOf( + 1.seconds, + 3.seconds, + 8.seconds, + 20.seconds, + 45.seconds, + 90.seconds, + ) fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) } - private val stateStore = PrivatePaykitStateStore(keychain, cacheStore) - private val recoveryStore = PrivatePaykitRecoveryStore(pubkyService, keychain) { ensureState() } - private val activeHandlesByContact = mutableMapOf() - private val knownSavedContactKeys = mutableSetOf() - private val linkEstablishmentMutex = Mutex() private val publicationMutex = Mutex() private val serializedDispatcher = ioDispatcher.limitedParallelism(1) - private val retryScope = CoroutineScope(SupervisorJob() + serializedDispatcher) - private val pendingPublicationRetryJobs = mutableMapOf() - private val stateGeneration = AtomicLong(0L) + private val retryScope = CoroutineScope(serializedDispatcher + SupervisorJob()) + private val knownSavedContactKeys = mutableSetOf() + private var state: PrivatePaykitState? = null + private var pendingMessageDrainRetryJob: Job? = null + + private data class PrivatePublicationPreparation( + val updates: List, + val firstError: Throwable?, + ) private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -116,86 +109,42 @@ class PrivatePaykitRepo @Inject constructor( if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable return@runCatching } - if (isProfileRecoveryPending() && keys.isNotEmpty()) { - recoverSavedContactsAfterProfileRecreation( - publicKeys = keys, - requireImmediatePublication = requireImmediatePublication, - ).getOrThrow() - return@runCatching - } + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() publishLocalEndpoints( publicKeys = keys, - maxAdvanceSteps = 3, reason = "prepare", requireImmediatePublication = requireImmediatePublication, ).getOrThrow() } } - private suspend fun recoverSavedContactsAfterProfileRecreation( + suspend fun enableSharingAndPrepareSavedContacts( publicKeys: Collection, - requireImmediatePublication: Boolean, + requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { runCatching { - val keys = rememberSavedContacts(publicKeys, replacing = true) - if (keys.isEmpty()) return@runCatching - if (!canPublishPrivateEndpoints()) return@runCatching - - advanceStateGeneration() - resetInFlightWork() - val didPurgeStaleTransport = recoveryStore.purgePrivatePaymentOutboxForProfileRecovery("profile recovery") - if (!didPurgeStaleTransport) { - updateProfileRecoveryPending(true) + val wasCleanupPending = isContactSharingCleanupPending() + if (wasCleanupPending && !canPublishPrivateEndpoints()) { if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return@runCatching } - markContactsForProfileRecovery(keys, clock.now().epochSeconds) - persistState(markWalletBackup = true) - updateProfileRecoveryPending(false) - addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() - publishLocalEndpoints( - publicKeys = keys, - maxAdvanceSteps = 3, - reason = "profile recovery", - forceLocalPublishWhenRemoteEmpty = true, - requireImmediatePublication = requireImmediatePublication, - ).getOrThrow() - } - } - - suspend fun enableSharingAndPrepareSavedContacts( - publicKeys: Collection, - requireImmediatePublication: Boolean = false, - ): Result = - withContext(serializedDispatcher) { - runCatching { - val wasCleanupPending = isContactSharingCleanupPending() - if (wasCleanupPending && !canPublishPrivateEndpoints()) { - if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable - return@runCatching + updateContactSharingCleanupPending(false) + prepareSavedContacts(publicKeys, requireImmediatePublication).onFailure { + if (wasCleanupPending) { + runCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) } - updateContactSharingCleanupPending(false) - prepareSavedContacts(publicKeys, requireImmediatePublication).onFailure { - if (wasCleanupPending) { - runCatching { updateContactSharingCleanupPending(true) } - .onFailure(it::addSuppressed) - } - }.getOrThrow() - } + }.getOrThrow() } + } suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { runCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) if (!canPublishPrivateEndpoints()) return@runCatching - if (isProfileRecoveryPending() && keys.isNotEmpty()) { - recoverSavedContactsAfterProfileRecreation(keys, requireImmediatePublication = false).getOrThrow() - return@runCatching - } - publishLocalEndpoints(keys, maxAdvanceSteps = 1, reason = "refresh").getOrThrow() + publishLocalEndpoints(keys, reason = "refresh").getOrThrow() } } @@ -205,16 +154,8 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runCatching { if (!canPublishPrivateEndpoints()) return@runCatching - if (isProfileRecoveryPending() && knownSavedContactKeys.isNotEmpty()) { - recoverSavedContactsAfterProfileRecreation( - publicKeys = knownSavedContactKeys.toList(), - requireImmediatePublication = false, - ).getOrThrow() - return@runCatching - } publishLocalEndpoints( publicKeys = knownSavedContactKeys.toList(), - maxAdvanceSteps = 1, reason = reason, forceRefreshLightning = forceRefreshLightning, ).getOrThrow() @@ -253,12 +194,10 @@ class PrivatePaykitRepo @Inject constructor( runCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: return@runCatching knownSavedContactKeys.remove(normalizedKey) - cancelPendingPublicationRetry(normalizedKey) - advanceStateGeneration() removePublishedEndpoints(normalizedKey).onFailure { updateDeletedContactCleanupPending(normalizedKey, true) Logger.warn( - "Failed to tombstone private Paykit endpoints for '${redacted(normalizedKey)}'", + "Failed to remove private Paykit endpoints for '${redacted(normalizedKey)}'", it, context = TAG, ) @@ -272,7 +211,6 @@ class PrivatePaykitRepo @Inject constructor( suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runCatching { - resetInFlightWork() val removalError = removePublishedEndpoints().exceptionOrNull() if (removalError != null) { updateContactSharingCleanupPending(true) @@ -282,10 +220,10 @@ class PrivatePaykitRepo @Inject constructor( context = TAG, ) throw removalError - } else { - clearUnsavedContactState(savedPublicKeys).getOrThrow() - updateContactSharingCleanupPending(false) } + + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) } } @@ -296,35 +234,24 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun removePublishedEndpointsBestEffort(context: String): Result = withContext(serializedDispatcher) { + suspend fun removePublishedEndpointsForCleanup(context: String): Result = withContext(serializedDispatcher) { removePublishedEndpoints() .onFailure { Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) } } - suspend fun closeAndClear( - markProfileRecoveryPending: Boolean = false, - ): Result = withContext(serializedDispatcher) { + suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runCatching { publicationMutex.withLock { - linkEstablishmentMutex.withLock { - val hadPrivateContactState = - ensureState().contacts.isNotEmpty() - val wasProfileRecoveryPending = isProfileRecoveryPending() - resetInFlightWork() - closeActiveHandles() - activeHandlesByContact.clear() - knownSavedContactKeys.clear() - stateStore.replaceState(PrivatePaykitState()) - keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) - cacheStore.reset() - if (markProfileRecoveryPending && (hadPrivateContactState || wasProfileRecoveryPending)) { - updateProfileRecoveryPending(true) - } - addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) - notifyBackupStateChanged() - } + pendingMessageDrainRetryJob?.cancel() + pendingMessageDrainRetryJob = null + knownSavedContactKeys.clear() + state = PrivatePaykitState() + cacheStore.reset() + addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) + paykitSdkService.clearState() + notifyBackupStateChanged() } } } @@ -334,40 +261,17 @@ class PrivatePaykitRepo @Inject constructor( runCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() - if (!hasLocalSecretKeyForCurrentProfile()) { - return@runCatching publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() - } - val privateAttempt = beginPrivatePaymentWithRecoveryRetry(normalizedKey) - val privateResult = privateAttempt.result - .onFailure { - if (it is CancellationException) throw it - } - .getOrNull() - - if (privateResult is PublicPaykitPaymentResult.Opened) return@runCatching privateResult - if ( - privateAttempt.shouldDeferPublicFallback || - shouldDeferPublicFallbackForPrivateRecovery(normalizedKey) - ) { - privateAttempt.result.exceptionOrNull()?.let { - Logger.warn( - "Deferring public Paykit fallback for '${redacted(normalizedKey)}' " + - "while private payment recovery completes", - it, - context = TAG, - ) - } - return@runCatching privateResult ?: PublicPaykitPaymentResult.NoEndpoint - } - privateAttempt.result.exceptionOrNull()?.let { + val result = beginContactPayment(normalizedKey).getOrElse { + if (it is CancellationException) throw it Logger.warn( - "Falling back to public Paykit for '${redacted(normalizedKey)}'", + "Failed to resolve Paykit contact payment for '${redacted(normalizedKey)}'", it, context = TAG, ) + return@runCatching PublicPaykitPaymentResult.NoEndpoint } - publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() + result } } @@ -420,19 +324,13 @@ class PrivatePaykitRepo @Inject constructor( matchingContacts.forEach { rememberReceivedInvoicePaymentHash(paymentHash, it) } if (!canPublishPrivateEndpoints()) return@runCatching - val generation = currentStateGeneration() - matchingContacts.forEach { publicKey -> - val linkId = establishedLinkId(publicKey, maxAdvanceSteps = 1, generation = generation) - .getOrNull() ?: return@forEach - publishLocalEndpoints(publicKey, linkId, force = true, generation = generation).onFailure { - schedulePendingPublicationRetry(publicKey) - Logger.warn( - "Failed to rotate private Paykit invoice for '${redacted(publicKey)}'", - it, - context = TAG, - ) - } - } + publishLocalEndpoints( + publicKeys = matchingContacts, + reason = "invoice rotation", + forceRefreshLightning = true, + ).onFailure { + Logger.warn("Failed to rotate private Paykit invoice", it, context = TAG) + }.getOrThrow() } } @@ -460,7 +358,7 @@ class PrivatePaykitRepo @Inject constructor( publicKeys.forEach { addressReservationRepo.rotateAddress(it).getOrThrow() } - publishLocalEndpoints(publicKeys, maxAdvanceSteps = 1, reason = "on-chain rotation").getOrThrow() + publishLocalEndpoints(publicKeys, reason = "on-chain rotation").getOrThrow() } } @@ -482,1157 +380,416 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun backupSnapshot(): Result?> = + suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { runCatching { - ensureState().contacts.mapNotNull { (publicKey, contactState) -> - if (!contactState.hasBackupState) return@mapNotNull null - publicKey to PrivatePaykitContactLinkBackupV1( - publicKey = publicKey, - linkSnapshotHex = contactState.linkSnapshotHex, - handshakeSnapshotHex = contactState.handshakeSnapshotHex, - remoteEndpoints = contactState.remoteEndpoints.associate { it.methodId to it.endpointData }, - linkCompletedAt = contactState.linkCompletedAt, - handshakeUpdatedAt = contactState.handshakeUpdatedAt, - recoveryStartedAt = contactState.recoveryStartedAt, - mainRecoveryAttemptId = contactState.mainRecoveryAttemptId, - responderRecoveryAttemptId = contactState.responderRecoveryAttemptId, - awaitingRecoveredRemoteEndpoints = contactState.awaitingRecoveredRemoteEndpoints, - ) - }.toMap().takeIf { it.isNotEmpty() } + pubkyService.currentPublicKey() ?: return@runCatching null + paykitSdkService.exportBackupState() } } - suspend fun restoreBackup(backup: Map?): Result = + suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { runCatching { - publicationMutex.withLock { - linkEstablishmentMutex.withLock { - resetInFlightWork() - closeActiveHandles() - activeHandlesByContact.clear() - knownSavedContactKeys.clear() - - if (backup == null) { - stateStore.replaceState(PrivatePaykitState()) - persistState(preserveCleanupMarkers = false) - notifyBackupStateChanged() - return@runCatching - } - - val contacts = backup.mapNotNull { (publicKey, contactBackup) -> - val normalizedKey = normalizedPublicKey(publicKey) ?: return@mapNotNull null - val linkSnapshotHex = validatedSnapshot( - contactBackup.linkSnapshotHex, - normalizedKey, - pubkyService::encryptedLinkSnapshotRecipient, - ) - val handshakeSnapshotHex = validatedSnapshot( - contactBackup.handshakeSnapshotHex, - normalizedKey, - pubkyService::encryptedLinkHandshakeSnapshotRecipient, - ) - normalizedKey to ContactState( - linkSnapshotHex = linkSnapshotHex, - handshakeSnapshotHex = handshakeSnapshotHex, - remoteEndpoints = PrivatePaykitPayloads.storedPaymentEntries( - contactBackup.remoteEndpoints, - ), - linkCompletedAt = contactBackup.linkCompletedAt, - handshakeUpdatedAt = contactBackup.handshakeUpdatedAt, - recoveryStartedAt = contactBackup.recoveryStartedAt, - mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId, - responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId, - awaitingRecoveredRemoteEndpoints = contactBackup.awaitingRecoveredRemoteEndpoints, - ) - }.toMap() - - stateStore.replaceState(PrivatePaykitState(contacts = contacts.toMutableMap())) - } + pendingMessageDrainRetryJob?.cancel() + pendingMessageDrainRetryJob = null + state = PrivatePaykitState() + knownSavedContactKeys.clear() + if (backup == null) { + paykitSdkService.clearState() + } else { + paykitSdkService.restoreBackupState(backup) } persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() } } - private suspend fun beginPrivatePayment(publicKey: String): Result = + private suspend fun beginContactPayment(publicKey: String): Result = withContext(serializedDispatcher) { runCatching { - val generation = currentStateGeneration() - val linkId = establishedLinkId(publicKey, maxAdvanceSteps = 5, generation = generation).getOrThrow() - ?: throw PrivatePaykitError.PrivateUnavailable - - if (ensureState().contacts[publicKey]?.lastLocalPayloadHash == null) { - publishLocalEndpointsBestEffort( - publicKey = publicKey, - linkId = linkId, - fetchedRemoteCount = 0, - context = "payment", - generation = generation, - ) - } - - var staleFetchError: Throwable? = null - val fetchedCount = fetchRemoteEndpoints(publicKey, linkId, generation).getOrElse { - if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) { - Logger.warn( - "Private Paykit link is stale for '${redacted(publicKey)}'; using cached private endpoints", - it, - context = TAG, - ) - staleFetchError = it - schedulePendingPublicationRetry(publicKey) - } else { + pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive + if (canPublishPrivateEndpoints()) { + publishLocalEndpoints( + publicKeys = listOf(publicKey), + reason = "payment", + ).onFailure { Logger.warn( - "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", + "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", it, context = TAG, ) } - 0 } - if (staleFetchError == null) { - val publishLinkId = activeHandlesByContact[publicKey]?.linkId ?: linkId - publishLocalEndpointsBestEffort( - publicKey = publicKey, - linkId = publishLinkId, - fetchedRemoteCount = fetchedCount, - context = "payment", - generation = generation, - respectInitialPublishDelay = false, - ) - } - - val cachedResult = cachedPrivatePaymentResult(publicKey) - if (cachedResult is PublicPaykitPaymentResult.Opened) { - clearAwaitingRecoveredRemoteEndpoints(publicKey) - return@runCatching cachedResult - } - staleFetchError?.let { throw it } - - cachedResult - } - } - private suspend fun beginPrivatePaymentWithRecoveryRetry(publicKey: String): PrivatePaymentAttempt { - var shouldDeferPublicFallback = shouldDeferPublicFallbackForPrivateRecovery(publicKey) - var result = runCatching { beginPrivatePayment(publicKey).getOrThrow() } - repeat(PRIVATE_PAYMENT_RECOVERY_RETRY_ATTEMPTS) { - shouldDeferPublicFallback = shouldDeferPublicFallback || - shouldDeferPublicFallbackForPrivateRecovery(publicKey) - if (!shouldRetryPrivatePaymentBeforePublicFallback(publicKey, result, shouldDeferPublicFallback)) { - return PrivatePaymentAttempt(result, shouldDeferPublicFallback) - } - delay(privatePaymentRecoveryRetryDelay) - result = runCatching { beginPrivatePayment(publicKey).getOrThrow() } - } - shouldDeferPublicFallback = shouldDeferPublicFallback || - shouldDeferPublicFallbackForPrivateRecovery(publicKey) - return PrivatePaymentAttempt(result, shouldDeferPublicFallback) - } - - private suspend fun shouldRetryPrivatePaymentBeforePublicFallback( - publicKey: String, - result: Result, - shouldDeferPublicFallback: Boolean, - ): Boolean { - if (result.getOrNull() is PublicPaykitPaymentResult.Opened) return false - result.exceptionOrNull()?.let { - if (it is CancellationException) throw it - if (it is PrivatePaykitError.PrivateUnavailable) { - return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey) - } - } - return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey) - } + val resolution = paykitSdkService.prepareAndResolveContactPayment( + counterparty = publicKey, + includePublicEndpoints = true, + ) + val privateEndpoints = resolution.payableEndpoints + .filter { it.source == PaymentEndpointSource.PRIVATE_PAYMENT_LIST } + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - private suspend fun shouldDeferPublicFallbackForPrivateRecovery(publicKey: String): Boolean { - val contactState = ensureState().contacts[publicKey] ?: return false - return contactState.recoveryStartedAt != null || - contactState.mainRecoveryAttemptId != null || - contactState.responderRecoveryAttemptId != null || - contactState.awaitingRecoveredRemoteEndpoints - } + cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) - private suspend fun clearAwaitingRecoveredRemoteEndpoints(publicKey: String) { - val contactState = ensureState().contacts[publicKey] ?: return - if (!contactState.awaitingRecoveredRemoteEndpoints) return + val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) + if (privatePayable.isNotEmpty()) { + return@runCatching PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(privatePayable)) + } - contactState.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup = true) - } + val publicEndpoints = resolution.payableEndpoints + .filter { it.source == PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT } + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + val publicPayable = publicPaykitRepo.payableEndpoints(publicEndpoints) + if (publicPayable.isNotEmpty()) { + return@runCatching PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(publicPayable)) + } - private suspend fun cachedPrivatePaymentResult(publicKey: String): PublicPaykitPaymentResult { - val cachedEntries = ensureState().contacts[publicKey]?.remoteEndpoints.orEmpty() - val endpoints = cachedEntries.mapNotNull { - PublicPaykitRepo.parseEndpoint(it.methodId, it.endpointData) - } - val payable = privatePayableEndpoints(endpoints, publicKey) - if (payable.isEmpty()) { - return when { - cachedEntries.isEmpty() -> PublicPaykitPaymentResult.NoEndpoint - else -> PublicPaykitPaymentResult.NotOpened + if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { + PublicPaykitPaymentResult.NoEndpoint + } else { + PublicPaykitPaymentResult.NotOpened + } } } - return PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable)) - } - - @Suppress("CyclomaticComplexMethod", "LongMethod") private suspend fun publishLocalEndpoints( publicKeys: Collection, - maxAdvanceSteps: Int, reason: String, - scheduleRetries: Boolean = true, - forceLocalPublishWhenRemoteEmpty: Boolean = false, forceRefreshLightning: Boolean = false, requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { runCatching { - val generation = currentStateGeneration() - var firstError: Throwable? = null - publicKeys.forEach { publicKey -> - val normalizedKey = knownSavedContact(publicKey) ?: return@forEach - val redactedKey = redacted(normalizedKey) - val linkId = establishedLinkIdForPublish( - publicKey = normalizedKey, - redactedKey = redactedKey, - maxAdvanceSteps = maxAdvanceSteps, - generation = generation, - scheduleRetries = scheduleRetries, - ) ?: run { - if (firstError == null && requireImmediatePublication) { - firstError = PrivatePaykitError.PrivateUnavailable - } - return@forEach - } + val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() + if (keys.isEmpty()) return@runCatching - if (publishLocalEndpointsBeforeFetch(normalizedKey, linkId, reason, scheduleRetries, generation)) { - return@forEach + publicationMutex.withLock { + if (!canPublishPrivateEndpoints()) { + if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable + return@withLock } - val fetchedCount = fetchRemoteEndpointCountForPublish( - publicKey = normalizedKey, - linkId = linkId, + pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive + val preparation = preparePrivatePaymentListReservations( + publicKeys = keys, reason = reason, - scheduleRetries = scheduleRetries, - generation = generation, - ) ?: run { - if (firstError == null && requireImmediatePublication) { - firstError = PrivatePaykitError.PrivateUnavailable + forceRefreshLightning = forceRefreshLightning, + ) + + if (preparation.updates.isEmpty()) { + if (requireImmediatePublication) { + throw preparation.firstError ?: PrivatePaykitError.PrivateUnavailable } - return@forEach + return@withLock } - val contactState = ensureState().contacts[normalizedKey] - val shouldForcePublish = forceLocalPublishWhenRemoteEmpty && - fetchedCount == 0 && - contactState?.remoteEndpoints.isNullOrEmpty() - val publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?: linkId - val publishResult = publishLocalEndpoints( - publicKey = normalizedKey, - linkId = publishLinkId, - force = shouldForcePublish, - generation = generation, - forceRefreshLightning = forceRefreshLightning, - ).onFailure { - if (scheduleRetries) schedulePendingPublicationRetry(normalizedKey) + + val report = paykitSdkService.syncPrivatePaymentListsWithReservations( + updates = preparation.updates, + clearUnlistedLinkedPeers = false, + ) + val firstError = preparation.firstError ?: applyPrivatePaymentListDeliveryReport(report, reason) + val retryKeys = privatePaymentListDeliveryRetryKeys(report) + drainPendingPrivateMessages(reason, advancingLinksFor = retryKeys) + if (retryKeys.isNotEmpty()) { + schedulePendingPrivateMessageDrainRetries(reason, publicKeys = retryKeys) + } + + if (firstError != null) { + if (requireImmediatePublication) throw firstError Logger.warn( - "Failed to publish private Paykit endpoints during '$reason' for '$redactedKey'", - it, + "Deferred private Paykit endpoint publish during '$reason'", + firstError, context = TAG, ) - if (firstError == null && requireImmediatePublication) firstError = it - } - val updatedContactState = ensureState().contacts[normalizedKey] - val needsRetry = publishResult.isFailure || - updatedContactState?.linkCompletedAt == null || - updatedContactState.lastLocalPayloadHash == null || - (fetchedCount == 0 && updatedContactState.remoteEndpoints.isEmpty()) || - shouldRetryMissingPrivateLightningEndpoint(normalizedKey) - if (scheduleRetries && needsRetry) { - schedulePendingPublicationRetry(normalizedKey) - } else { - cancelPendingPublicationRetry(normalizedKey) } } - firstError?.let { throw it } - Unit } } - private suspend fun establishedLinkIdForPublish( - publicKey: String, - redactedKey: String, - maxAdvanceSteps: Int, - generation: Long, - scheduleRetries: Boolean, - ): String? = - establishedLinkId(publicKey, maxAdvanceSteps, generation).fold( - onSuccess = { - if (it == null) { - if (scheduleRetries) schedulePendingPublicationRetry(publicKey) - Logger.debug( - "Deferred private Paykit endpoint publish for '$redactedKey'", - context = TAG, - ) - } - it - }, - onFailure = { - val shouldRetry = PrivatePaykitErrorClassifier.shouldRetryLinkEstablishmentFailure(it) - if (scheduleRetries && shouldRetry) schedulePendingPublicationRetry(publicKey) - Logger.debug( - if (shouldRetry) { - "Deferred private Paykit endpoint publish for '$redactedKey'" - } else { - "Skipped private Paykit endpoint publish for '$redactedKey'" - }, + private suspend fun preparePrivatePaymentListReservations( + publicKeys: Collection, + reason: String, + forceRefreshLightning: Boolean, + ): PrivatePublicationPreparation { + var firstError: Throwable? = null + val updates = mutableListOf() + + for (publicKey in publicKeys) { + runCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, context = TAG, ) - null - }, - ) + } - private suspend fun publishLocalEndpointsBeforeFetch( - publicKey: String, - linkId: String, - reason: String, - scheduleRetries: Boolean, - generation: Long, - ): Boolean { - if (!contactStateShouldPublishBeforeFetch(publicKey)) return false - - val publishResult = publishLocalEndpoints( - publicKey = publicKey, - linkId = linkId, - generation = generation, - ).onFailure { - if (scheduleRetries) schedulePendingPublicationRetry(publicKey) - Logger.warn( - "Failed to publish private Paykit endpoints during '$reason' for '${redacted(publicKey)}'", - it, - context = TAG, - ) + val endpointResult = buildLocalEndpoints(publicKey, forceRefreshLightning) + val endpointError = endpointResult.exceptionOrNull() + if (endpointError != null) { + firstError = firstError ?: endpointError + Logger.warn( + "Failed to prepare private Paykit endpoints for '${redacted(publicKey)}' during '$reason'", + endpointError, + context = TAG, + ) + } else if (endpointResult.getOrThrow().isEmpty()) { + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + Logger.warn( + "Skipped private Paykit endpoint publish for '${redacted(publicKey)}' during '$reason'", + context = TAG, + ) + } else { + val endpoints = endpointResult.getOrThrow() + updates += PrivatePaymentListReservationUpdateInput( + counterparty = publicKey, + reservations = endpoints.map { endpoint -> + privateReservation(publicKey, endpoint) + }, + ) + } } - if (publishResult.isFailure) return false - if (scheduleRetries) schedulePendingPublicationRetry(publicKey) - return true + return PrivatePublicationPreparation(updates, firstError) } - private suspend fun fetchRemoteEndpointCountForPublish( - publicKey: String, - linkId: String, + private suspend fun applyPrivatePaymentListDeliveryReport( + report: PrivatePaymentListDeliveryReport, reason: String, - scheduleRetries: Boolean, - generation: Long, - ): Int? = fetchRemoteEndpoints(publicKey, linkId, generation).fold( - onSuccess = { it }, - onFailure = { - if (scheduleRetries) { - schedulePendingPublicationRetry(publicKey) - } + ): Throwable? { + report.failedToQueue.forEach { Logger.warn( - "Failed to fetch private Paykit endpoints during '$reason' for '${redacted(publicKey)}'", - it, + "Failed to queue private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + + (it.error ?: "unknown error"), context = TAG, ) - if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) null else 0 - }, - ) - - private suspend fun publishLocalEndpointsBestEffort( - publicKey: String, - linkId: String, - fetchedRemoteCount: Int, - context: String, - generation: Long = currentStateGeneration(), - respectInitialPublishDelay: Boolean = true, - forceRefreshLightning: Boolean = false, - ) { - if (!canPublishPrivateEndpoints()) return - if (!shouldPublishLocalEndpoints(publicKey, fetchedRemoteCount)) return - if (respectInitialPublishDelay && shouldDeferInitialLocalPublish(publicKey, fetchedRemoteCount)) return - - publishLocalEndpoints( - publicKey = publicKey, - linkId = linkId, - generation = generation, - forceRefreshLightning = forceRefreshLightning, - ).onFailure { - schedulePendingPublicationRetry(publicKey) + } + report.failedToDeliver.forEach { Logger.warn( - "Failed to publish private Paykit endpoints during '$context' for '${redacted(publicKey)}'", - it, + "Failed to deliver private Paykit endpoints for '${redacted(it.counterparty)}' during '$reason': " + + it.error, context = TAG, ) } - } - - private fun schedulePendingPublicationRetry( - publicKey: String, - remainingAttempts: Int = PENDING_PUBLICATION_RETRY_ATTEMPTS, - ) { - if (remainingAttempts <= 0) return - if (publicKey !in knownSavedContactKeys) return - if (pendingPublicationRetryJobs[publicKey] != null) return - - pendingPublicationRetryJobs[publicKey] = retryScope.launch { - delay(pendingPublicationRetryDelay) - pendingPublicationRetryJobs.remove(publicKey) - if (publicKey !in knownSavedContactKeys) return@launch - if (!canPublishPrivateEndpoints()) return@launch - - publishLocalEndpoints( - publicKeys = listOf(publicKey), - maxAdvanceSteps = 3, - reason = "retry", - scheduleRetries = false, - forceLocalPublishWhenRemoteEmpty = true, - ).onFailure { - Logger.warn( - "Failed to retry private Paykit endpoints for '${redacted(publicKey)}'", - it, - context = TAG, - ) - } - val contactState = ensureState().contacts[publicKey] - val needsRetry = contactState?.linkCompletedAt == null || - contactState.lastLocalPayloadHash == null || - contactState.remoteEndpoints.isEmpty() || - shouldRetryMissingPrivateLightningEndpoint(publicKey) - if (needsRetry) schedulePendingPublicationRetry(publicKey, remainingAttempts - 1) + var didUpdateCache = false + for (change in report.queued) { + val publicKey = normalizedPublicKey(change.counterparty) ?: continue + ensureState().contacts.getOrPut(publicKey) { ContactState() }.hasPublishedPrivatePaymentList = true + updateDeletedContactCleanupPending(publicKey, isPending = false) + didUpdateCache = true } - } - private fun cancelPendingPublicationRetry(publicKey: String) { - pendingPublicationRetryJobs.remove(publicKey)?.cancel() - } + for (change in report.cleared) { + didUpdateCache = clearPublishedPrivatePaymentListCache(change.counterparty) || didUpdateCache + } - private fun resetInFlightWork() { - advanceStateGeneration() - pendingPublicationRetryJobs.values.forEach { it.cancel() } - pendingPublicationRetryJobs.clear() - } + if (didUpdateCache) { + persistState(markWalletBackup = true) + } - private fun advanceStateGeneration() { - stateGeneration.incrementAndGet() + return PrivatePaykitError.PrivateUnavailable.takeIf { + report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty() + } } - private fun currentStateGeneration(): Long = stateGeneration.get() - - private fun ensureCurrentGeneration(generation: Long) { - if (stateGeneration.get() != generation) throw PrivatePaykitError.PrivateUnavailable - } + private fun privatePaymentListDeliveryRetryKeys(report: PrivatePaymentListDeliveryReport): List = + (report.queued.map { it.counterparty } + report.failedToDeliver.map { it.counterparty }) + .mapNotNull { normalizedPublicKey(it) } + .distinct() - private suspend fun publishLocalEndpoints( - publicKey: String, - linkId: String, - force: Boolean = false, - generation: Long = currentStateGeneration(), - forceRefreshLightning: Boolean = false, - ): Result = withContext(serializedDispatcher) { + private suspend fun drainPendingPrivateMessages(reason: String, advancingLinksFor: List = emptyList()) { runCatching { - publicationMutex.withLock { - ensureCurrentGeneration(generation) - if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock - - val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() - if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint - ensureCurrentGeneration(generation) - val payloadSelection = PrivatePaykitPayloads.entriesWithinNoiseLimit(endpoints) - if (payloadSelection.droppedLightning) { + advancingLinksFor.forEach { publicKey -> + runCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { Logger.warn( - "Published private Paykit on-chain only for '${redacted(publicKey)}'", + "Failed to advance private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, context = TAG, ) } - val entries = payloadSelection.entries - val payloadHash = PrivatePaykitPayloads.localPayloadHash(entries) - val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - if (!force && contactState.lastLocalPayloadHash == payloadHash) return@withLock - ensureCurrentGeneration(generation) - if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock - - pubkyService.setPrivatePayments(linkId, entries.map { FfiPaymentEntry(it.methodId, it.endpointData) }) - ensureCurrentGeneration(generation) - persistLinkSnapshot(linkId, publicKey, linkWasReplaced = false, generation = generation).getOrThrow() - contactState.lastLocalPayloadHash = payloadHash - persistState(markWalletBackup = false) } + paykitSdkService.processPendingPrivateMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers() + paykitSdkService.processPendingPrivateMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers() }.onFailure { - recordLinkFailure(publicKey, it, generation) + Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) } } - private suspend fun buildLocalEndpoints( - publicKey: String, - forceRefreshLightning: Boolean = false, - ): Result> = - withContext(serializedDispatcher) { - runCatching { - val settings = settingsStore.data.first() - val endpoints = mutableListOf() - if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { - val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() - walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() - endpoints += Endpoint( - methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), - value = reservedAddress, - rawPayload = PublicPaykitRepo.serializePayload(reservedAddress), - ) - } - - if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { - currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice -> - endpoints += Endpoint( - methodId = MethodId.Bolt11, - value = invoice.bolt11, - rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11), - ) - }.onFailure { - if (it is PrivatePaykitError.RouteHintsUnavailable) { - schedulePendingPublicationRetry(publicKey) - } - Logger.warn( - "Failed to prepare private Paykit invoice for '${redacted(publicKey)}'", - it, - context = TAG, - ) - } - } - - endpoints + private fun schedulePendingPrivateMessageDrainRetries(reason: String, publicKeys: List) { + pendingMessageDrainRetryJob?.cancel() + pendingMessageDrainRetryJob = retryScope.launch { + for (retryDelay in privateMessageDrainRetryDelays) { + delay(retryDelay) + drainPendingPrivateMessages("$reason retry", advancingLinksFor = publicKeys) } } - - private suspend fun currentOrRotatedInvoice( - publicKey: String, - forceRefresh: Boolean = false, - ): Result = - withContext(serializedDispatcher) { - runCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } - - val bolt11 = lightningRepo.createInvoice( - amountSats = null, - description = "", - expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), - ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } - - val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice - ?: throw PublicPaykitError.InvalidPayload - if (!PublicPaykitRepo.hasLightningRouteHints(bolt11)) { - throw PrivatePaykitError.RouteHintsUnavailable - } - val expiresAt = decoded.timestampSeconds.toLong() + decoded.expirySeconds.toLong() - val invoice = StoredInvoice( - bolt11 = bolt11, - paymentHash = decoded.paymentHash.toHex(), - expiresAt = expiresAt, - ) - ensureState().contacts.getOrPut(publicKey) { ContactState() }.localInvoice = invoice - persistState() - invoice - } - } - - @Suppress("ReturnCount") - private suspend fun reusablePrivateInvoice(publicKey: String): StoredInvoice? { - val invoice = ensureState().contacts[publicKey]?.localInvoice ?: return null - val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds - if (invoice.expiresAt <= refreshAt) return null - if (isReceivedInvoiceSettled(invoice.paymentHash)) return null - val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null - if (decoded.isExpired || decoded.amountSatoshis != 0uL) return null - if (!PublicPaykitRepo.hasLightningRouteHints(invoice.bolt11)) return null - return invoice - } - - private suspend fun shouldRetryMissingPrivateLightningEndpoint(publicKey: String): Boolean { - val settings = settingsStore.data.first() - if (!PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) return false - if (!lightningRepo.canReceive()) return false - - return reusablePrivateInvoice(publicKey) == null } - private suspend fun fetchRemoteEndpoints( - publicKey: String, - linkId: String, - generation: Long = currentStateGeneration(), - ): Result = - withContext(serializedDispatcher) { - runCatching { - readRemoteEndpoints(publicKey, linkId, generation).getOrElse { error -> - if (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) throw error - - val restoredLinkId = restoreLinkHandleForReadRetry(publicKey, generation).getOrNull() - ?: throw error - - Logger.info( - "Retrying private Paykit endpoint fetch for '${redacted(publicKey)}'", - context = TAG, - ) - readRemoteEndpoints(publicKey, restoredLinkId, generation).getOrElse { - throw it - } - } - }.onFailure { - recordLinkFailure(publicKey, it, generation) - } - } - - private suspend fun readRemoteEndpoints( - publicKey: String, - linkId: String, - generation: Long, - ): Result = - withContext(serializedDispatcher) { - runCatching { - ensureCurrentGeneration(generation) - val remotePayload = pubkyService.getPrivatePayments(linkId) - ensureCurrentGeneration(generation) - recordLinkSuccess(publicKey) - persistLinkSnapshot(linkId, publicKey, linkWasReplaced = false, generation = generation).getOrThrow() - ensureCurrentGeneration(generation) - if (remotePayload == null) return@runCatching 0 - - val remoteEntries = remotePayload.entries - val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.remoteEndpoints = remoteEntries.map { StoredPaymentEntry(it.methodId, it.endpointData) } - persistState(markWalletBackup = true) - remoteEntries.count() - } - } - - private suspend fun restoreLinkHandleForReadRetry( - publicKey: String, - generation: Long, - ): Result = - withContext(serializedDispatcher) { - runCatching { - ensureCurrentGeneration(generation) - val contactState = ensureState().contacts[publicKey] ?: return@runCatching null - val snapshot = contactState.linkSnapshotHex ?: return@runCatching null - val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?.takeIf { it.isNotBlank() } - ?: return@runCatching null - - activeHandlesByContact[publicKey]?.linkId?.let { - runCatching { pubkyService.closeEncryptedLink(it) } - } - activeHandlesByContact[publicKey] = ContactPaykitHandles() - - ensureCurrentGeneration(generation) - validateSnapshot(snapshot, publicKey, pubkyService::encryptedLinkSnapshotRecipient) - val restoredLinkId = pubkyService.restoreEncryptedLink(secretKeyHex, snapshot) - ensureCurrentGeneration(generation) - activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId = restoredLinkId) - restoredLinkId - } - } - - @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") - private suspend fun establishedLinkId( - publicKey: String, - maxAdvanceSteps: Int, - generation: Long = currentStateGeneration(), - ): Result = - withContext(serializedDispatcher) { - runCatching { - linkEstablishmentMutex.withLock { - establishedLinkIdUnlocked(publicKey, maxAdvanceSteps, generation) - } + private suspend fun clearPublishedPrivatePaymentListCache(counterparty: String): Boolean { + val publicKey = normalizedPublicKey(counterparty) ?: return false + ensureState().contacts[publicKey]?.let { contactState -> + contactState.remoteEndpoints = emptyList() + contactState.localInvoice = null + contactState.hasPublishedPrivatePaymentList = false + if (!contactState.hasCacheState) { + state?.contacts?.remove(publicKey) } } + updateDeletedContactCleanupPending(publicKey, isPending = false) + return true + } - @Suppress( - "LongMethod", - "CyclomaticComplexMethod", - "ReturnCount", - "NestedBlockDepth", - "ComplexCondition", - "ThrowsCount", - ) - private suspend fun establishedLinkIdUnlocked( + private suspend fun buildLocalEndpoints( publicKey: String, - maxAdvanceSteps: Int, - generation: Long, - ): String? { - ensureCurrentGeneration(generation) - val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.PrivateUnavailable - - val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: throw PrivatePaykitError.PrivateUnavailable - val ownPublicKey = pubkyService.currentPublicKey() - ?.let { PubkyPublicKeyFormat.normalized(it) } - ?: throw PrivatePaykitError.PrivateUnavailable - ensureCurrentGeneration(generation) - - val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } - activeHandlesByContact[normalizedKey]?.linkId?.let { linkId -> - val remoteRecoveryMarker = recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_INIT), - ) - if (remoteRecoveryMarker != null && shouldReplaceUsableLink(remoteRecoveryMarker, normalizedKey)) { - if (!discardLinkForRecovery(normalizedKey, linkId, remoteRecoveryMarker.createdAt)) return null - } else { - return linkId - } - } - - contactState.linkSnapshotHex?.let { snapshot -> - val shouldRestoreSnapshot = runCatching { - snapshotRecipientMatches(snapshot, normalizedKey, pubkyService::encryptedLinkSnapshotRecipient) - }.getOrElse { - Logger.warn( - "Failed to inspect private Paykit link snapshot for '${redacted(normalizedKey)}'", - it, - context = TAG, + forceRefreshLightning: Boolean = false, + ): Result> = withContext(serializedDispatcher) { + runCatching { + val settings = settingsStore.data.first() + val endpoints = mutableListOf() + if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { + val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() + endpoints += Endpoint( + methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), + value = reservedAddress, + rawPayload = PublicPaykitRepo.serializePayload(reservedAddress), ) - clearInvalidLinkSnapshotState(contactState) - false } - if (!shouldRestoreSnapshot) { - if (contactState.linkSnapshotHex != null) { - Logger.warn( - "Dropped private Paykit link snapshot with mismatched recipient for " + - "'${redacted(normalizedKey)}'", - context = TAG, + if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings) && lightningRepo.canReceive()) { + currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice -> + endpoints += Endpoint( + methodId = MethodId.Bolt11, + value = invoice.bolt11, + rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11), ) - clearInvalidLinkSnapshotState(contactState) - } - } - - val restoredLinkId = if (shouldRestoreSnapshot) { - runCatching { - val linkId = pubkyService.restoreEncryptedLink(secretKeyHex, snapshot) - ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId = linkId) - linkId }.onFailure { - if (it is PrivatePaykitError.PrivateUnavailable) throw it Logger.warn( - "Failed to restore private Paykit link for '${redacted(normalizedKey)}'", + "Failed to prepare private Paykit invoice for '${redacted(publicKey)}'", it, context = TAG, ) - contactState.linkSnapshotHex = null - contactState.handshakeSnapshotHex = null - contactState.lastLocalPayloadHash = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - persistState(markWalletBackup = true) - }.getOrNull() - } else { - null - } - if (restoredLinkId != null) { - val remoteRecoveryMarker = recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_INIT), - ) - if (remoteRecoveryMarker != null && shouldReplaceUsableLink(remoteRecoveryMarker, normalizedKey)) { - val didDiscard = discardLinkForRecovery( - publicKey = normalizedKey, - linkId = restoredLinkId, - startedAt = remoteRecoveryMarker.createdAt, - ) - if (!didDiscard) return null - } else { - return restoredLinkId } } - } - val isRecovering = shouldStartRecoveryHandshake(normalizedKey) - val fetchedRemoteRecoveryInitMarker = recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_INIT), - ) - val remoteRecoveryInitMarker = fetchedRemoteRecoveryInitMarker - ?.takeUnless { isCompletedRecoveryMarker(it, normalizedKey) } - val remoteRecoveryFinalForResponder = contactState.responderRecoveryAttemptId?.let { - recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_FINAL), - attemptId = it, - ) - } - val remoteRecoveryMarker = remoteRecoveryInitMarker ?: remoteRecoveryFinalForResponder - - val initialMainRecoveryAttemptId = contactState.mainRecoveryAttemptId - val localMainRecoveryMarker = initialMainRecoveryAttemptId?.let { - recoveryStore.freshRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stages = setOf(RECOVERY_MARKER_STAGE_INIT, RECOVERY_MARKER_STAGE_FINAL), - attemptId = it, - ) - } - val shouldAcceptRemoteRecovery = if (remoteRecoveryFinalForResponder != null) { - true - } else { - remoteRecoveryMarker?.let { - shouldAcceptRemoteRecoveryMarker( - remoteMarker = it, - localMarker = localMainRecoveryMarker, - ownPublicKey = ownPublicKey, - remotePublicKey = normalizedKey, - ) - } ?: false + endpoints } + } - if (shouldAcceptRemoteRecovery && remoteRecoveryMarker != null) { - val isNewResponderAttempt = contactState.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId - if (isNewResponderAttempt) { - if (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery responder")) return null - ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey]?.handshakeId?.let { - runCatching { pubkyService.dropEncryptedLinkHandshake(it) } - } - activeHandlesByContact[normalizedKey] = ContactPaykitHandles() - contactState.handshakeSnapshotHex = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = remoteRecoveryMarker.attemptId - contactState.recoveryStartedAt = remoteRecoveryMarker.createdAt - contactState.lastLocalPayloadHash = null - contactState.remoteEndpoints = emptyList() - contactState.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup = true) - } - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_RESPONSE, - attemptId = remoteRecoveryMarker.attemptId, - createdAt = clock.now().epochSeconds, - ) - } + private suspend fun currentOrRotatedInvoice( + publicKey: String, + forceRefresh: Boolean = false, + ): Result = withContext(serializedDispatcher) { + runCatching { + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } - val shouldInitiateRecovery = isRecovering && !shouldAcceptRemoteRecovery - if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId == null) { - if (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery initiator")) return null - ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey]?.handshakeId?.let { - runCatching { pubkyService.dropEncryptedLinkHandshake(it) } - } - activeHandlesByContact[normalizedKey] = ContactPaykitHandles() - val attemptId = UUID.randomUUID().toString() - val createdAt = clock.now().epochSeconds - contactState.handshakeSnapshotHex = null - contactState.mainRecoveryAttemptId = attemptId - contactState.responderRecoveryAttemptId = null - contactState.recoveryStartedAt = createdAt - contactState.lastLocalPayloadHash = null - contactState.remoteEndpoints = emptyList() - contactState.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup = true) - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_INIT, - attemptId = attemptId, - createdAt = createdAt, - ) - } + val bolt11 = lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), + ).getOrThrow() + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } - if ( - shouldInitiateRecovery && - initialMainRecoveryAttemptId != null && - contactState.mainRecoveryAttemptId != null && - localMainRecoveryMarker == null - ) { - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_INIT, - attemptId = checkNotNull(contactState.mainRecoveryAttemptId), - createdAt = clock.now().epochSeconds, + val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice + ?: throw PublicPaykitError.InvalidPayload + if (!PublicPaykitRepo.hasLightningRouteHints(bolt11)) { + throw PrivatePaykitError.RouteHintsUnavailable + } + val expiresAt = decoded.timestampSeconds.toLong() + decoded.expirySeconds.toLong() + val invoice = StoredInvoice( + bolt11 = bolt11, + paymentHash = decoded.paymentHash.toHex(), + expiresAt = expiresAt, ) + ensureState().contacts.getOrPut(publicKey) { ContactState() }.localInvoice = invoice + persistState() + invoice } + } - if (isRecovering && !shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { - contactState.responderRecoveryAttemptId = null - persistState(markWalletBackup = true) - } - - if ( - shouldInitiateRecovery && - contactState.mainRecoveryAttemptId != null && - contactState.handshakeSnapshotHex != null - ) { - val attemptId = checkNotNull(contactState.mainRecoveryAttemptId) - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_INIT, - attemptId = attemptId, - createdAt = clock.now().epochSeconds, + private suspend fun reusablePrivateInvoice(publicKey: String): StoredInvoice? { + val invoice = ensureState().contacts[publicKey]?.localInvoice ?: return null + val refreshAt = clock.now().epochSeconds + invoiceRefreshBuffer.inWholeSeconds + val decoded = (coreService.decode(invoice.bolt11) as? Scanner.Lightning)?.invoice ?: return null + val isReusable = invoice.expiresAt > refreshAt && + !isReceivedInvoiceSettled(invoice.paymentHash) && + !decoded.isExpired && + decoded.amountSatoshis == 0uL && + PublicPaykitRepo.hasLightningRouteHints(invoice.bolt11) + return invoice.takeIf { isReusable } + } + + private fun privateReservation(publicKey: String, endpoint: Endpoint): PaymentEndpointReservationInput { + val contactState = state?.contacts?.get(publicKey) + val attribution = if (endpoint.methodId == MethodId.Bolt11) { + val paymentHash = contactState?.localInvoice?.takeIf { it.bolt11 == endpoint.value }?.paymentHash + mapOf( + "type" to "private_paykit", + "counterparty" to publicKey, + ) + listOfNotNull(paymentHash?.let { "payment_hash" to it }).toMap() + } else { + mapOf( + "type" to "private_paykit", + "counterparty" to publicKey, ) - val hasPeerProgress = recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_RESPONSE, RECOVERY_MARKER_STAGE_FINAL), - attemptId = attemptId, - ) != null - if (!hasPeerProgress) return null } + val expiresAt = contactState + ?.localInvoice + ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } + ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } + + return PaymentEndpointReservationInput( + reservationId = privateReservationId(publicKey, endpoint), + identifier = endpoint.methodId.rawValue, + payload = endpoint.rawPayload, + expiresAt = expiresAt, + attribution = attribution, + ) + } - if ( - shouldAcceptRemoteRecovery && - contactState.responderRecoveryAttemptId != null && - contactState.handshakeSnapshotHex != null - ) { - val attemptId = checkNotNull(contactState.responderRecoveryAttemptId) - val hasPeerFinal = recoveryStore.freshRecoveryMarker( - from = normalizedKey, - to = ownPublicKey, - stages = setOf(RECOVERY_MARKER_STAGE_FINAL), - attemptId = attemptId, - ) != null - if (!hasPeerFinal) { - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_RESPONSE, - attemptId = attemptId, - createdAt = clock.now().epochSeconds, - ) - return null - } - } - - var handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId - if (handshakeId == null) { - contactState.handshakeSnapshotHex?.let { snapshot -> - val shouldRestoreSnapshot = runCatching { - snapshotRecipientMatches( - snapshotHex = snapshot, - publicKey = normalizedKey, - recipient = pubkyService::encryptedLinkHandshakeSnapshotRecipient, - ) - }.getOrElse { - Logger.warn( - "Failed to inspect private Paykit handshake snapshot for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - clearInvalidHandshakeSnapshotState(contactState) - false - } - - if (!shouldRestoreSnapshot) { - if (contactState.handshakeSnapshotHex != null) { - Logger.warn( - "Dropped private Paykit handshake snapshot with mismatched recipient for " + - "'${redacted(normalizedKey)}'", - context = TAG, - ) - clearInvalidHandshakeSnapshotState(contactState) - } - return@let - } - - runCatching { - handshakeId = pubkyService.restoreEncryptedLinkHandshake(secretKeyHex, snapshot) - ensureCurrentGeneration(generation) - }.onFailure { - if (it is PrivatePaykitError.PrivateUnavailable) throw it - Logger.warn( - "Failed to restore private Paykit handshake for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - contactState.handshakeSnapshotHex = null - contactState.mainRecoveryAttemptId = null - persistState(markWalletBackup = true) - } - } - } - - if (handshakeId == null) { - val shouldInitiate = shouldInitiateRecovery || - (!shouldAcceptRemoteRecovery && shouldInitiate(ownPublicKey, normalizedKey)) - handshakeId = if (shouldInitiate) { - pubkyService.initiateEncryptedLink(secretKeyHex, normalizedKey) - } else { - pubkyService.acceptEncryptedLink(secretKeyHex, normalizedKey) - } - ensureCurrentGeneration(generation) - if (isRecovering) { - contactState.recoveryStartedAt = clock.now().epochSeconds - persistState(markWalletBackup = true) - } - } - - val isRecoveryHandshake = shouldInitiateRecovery || shouldAcceptRemoteRecovery - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) - repeat(maxAdvanceSteps) { - val progress = runCatching { pubkyService.advanceHandshake(checkNotNull(handshakeId)) } - .getOrElse { - if (PrivatePaykitErrorClassifier.isEncryptedHandshakePendingError(it)) { - val snapshot = pubkyService.serializeEncryptedLinkHandshake(checkNotNull(handshakeId)) - ensureCurrentGeneration(generation) - contactState.handshakeSnapshotHex = snapshot - contactState.handshakeUpdatedAt = clock.now().epochSeconds - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) - persistState(markWalletBackup = true) - return null - } - if (PrivatePaykitErrorClassifier.isEncryptedHandshakeStateFailure(it)) { - ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey] = ContactPaykitHandles() - contactState.handshakeSnapshotHex = null - contactState.mainRecoveryAttemptId = null - persistState(markWalletBackup = true) - } - throw it - } - ensureCurrentGeneration(generation) - - if (progress.status == HANDSHAKE_COMPLETE) { - val linkId = progress.handleId - val attemptId = contactState.mainRecoveryAttemptId ?: contactState.responderRecoveryAttemptId - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId = linkId) - contactState.handshakeSnapshotHex = null - contactState.recoveryStartedAt = null - persistLinkSnapshot( - linkId = linkId, - publicKey = normalizedKey, - linkWasReplaced = true, - generation = generation, - ).getOrThrow() - if (isRecoveryHandshake && attemptId != null) { - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_FINAL, - attemptId = attemptId, - createdAt = clock.now().epochSeconds, - ) - } - return linkId - } - - handshakeId = progress.handleId - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) - contactState.handshakeSnapshotHex = - pubkyService.serializeEncryptedLinkHandshake(checkNotNull(handshakeId)) - ensureCurrentGeneration(generation) - contactState.handshakeUpdatedAt = clock.now().epochSeconds - persistState(markWalletBackup = true) - - if (isRecoveryHandshake) { - val createdAt = clock.now().epochSeconds - if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId != null) { - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_INIT, - attemptId = checkNotNull(contactState.mainRecoveryAttemptId), - createdAt = createdAt, - ) - } else if (shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { - recoveryStore.publishRecoveryMarker( - from = ownPublicKey, - to = normalizedKey, - stage = RECOVERY_MARKER_STAGE_RESPONSE, - attemptId = checkNotNull(contactState.responderRecoveryAttemptId), - createdAt = createdAt, - ) - } - return null - } - } + private fun privateReservationId(publicKey: String, endpoint: Endpoint): String { + val payloadHashPrefix = MessageDigest.getInstance("SHA-256") + .digest(endpoint.rawPayload.toByteArray(Charsets.UTF_8)) + .copyOfRange(0, 8) + .toHex() + return "$publicKey:${endpoint.methodId.rawValue}:$payloadHashPrefix" + } - return null + private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + contactState.remoteEndpoints = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } + persistState(markWalletBackup = true) } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { runCatching { - resetInFlightWork() - var firstError: Throwable? = null - ensureState().contacts.keys.toList().forEach { - removePublishedEndpoints(it).onFailure { error -> - if (firstError == null) firstError = error - Logger.warn( - "Failed to remove private Paykit endpoints for '${redacted(it)}'", - error, - context = TAG, - ) - } - } - firstError?.let { throw it } - Unit + val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) + .distinct() + val firstError = keys.mapNotNull { publicKey -> + removePublishedEndpoints(publicKey).exceptionOrNull() + }.firstOrNull() + if (firstError != null) throw firstError } } private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { - val generation = currentStateGeneration() runCatching { - publicationMutex.withLock { - linkEstablishmentMutex.withLock { - ensureCurrentGeneration(generation) - val activeLinkId = activeHandlesByContact[publicKey]?.linkId - val restoredLinkId = ensureState().contacts[publicKey]?.linkSnapshotHex - ?.let { - val secretKey = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: return@let null - validateSnapshot(it, publicKey, pubkyService::encryptedLinkSnapshotRecipient) - pubkyService.restoreEncryptedLink(secretKey, it).also { linkId -> - ensureCurrentGeneration(generation) - activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId = linkId) - } - } - val linkId = activeLinkId ?: restoredLinkId - ?: runCatching { - establishedLinkIdUnlocked( - publicKey = publicKey, - maxAdvanceSteps = 5, - generation = generation, - ) - }.getOrNull() - ?: run { - if (shouldRequirePrivateEndpointRemoval(publicKey)) { - throw PrivatePaykitError.PrivateUnavailable - } - null - } - if (linkId == null) return@withLock - - val entries = PrivatePaykitPayloads.privateEndpointRemovalEntries() - PrivatePaykitPayloads.validateNoisePayload(entries) - pubkyService.setPrivatePayments( - linkId, - entries.map { FfiPaymentEntry(it.methodId, it.endpointData) }, - ) - ensureCurrentGeneration(generation) - ensureState().contacts[publicKey]?.lastLocalPayloadHash = null - ensureState().contacts[publicKey]?.localInvoice = null - persistLinkSnapshot( - linkId = linkId, - publicKey = publicKey, - linkWasReplaced = false, - generation = generation, - ).getOrThrow() - pubkyService.currentPublicKey() - ?.let { PubkyPublicKeyFormat.normalized(it) } - ?.let { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } + val report = paykitSdkService.clearPrivatePaymentList(counterparty = publicKey) + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { + throw PrivatePaykitError.PrivateUnavailable + } + state?.contacts?.get(publicKey)?.let { contactState -> + contactState.remoteEndpoints = emptyList() + contactState.localInvoice = null + contactState.hasPublishedPrivatePaymentList = false + if (!contactState.hasCacheState) { + state?.contacts?.remove(publicKey) } } - Unit - }.onFailure { - recordLinkFailure(publicKey, it, generation) + updateDeletedContactCleanupPending(publicKey, isPending = false) + persistState(markWalletBackup = true) } } @@ -1640,81 +797,19 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() - val staleKeys = ensureState().contacts.keys.filter { it !in savedKeys } - if (staleKeys.isNotEmpty()) advanceStateGeneration() - staleKeys.forEach { + ensureState().contacts.keys.filter { it !in savedKeys }.forEach { clearContactState(it) } addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) + persistState(markWalletBackup = true) } } private suspend fun clearContactState(publicKey: String) { - cancelPendingPublicationRetry(publicKey) - pubkyService.currentPublicKey() - ?.let { PubkyPublicKeyFormat.normalized(it) } - ?.let { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } - activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } - activeHandlesByContact[publicKey]?.handshakeId?.let { - runCatching { pubkyService.dropEncryptedLinkHandshake(it) } - } - activeHandlesByContact.remove(publicKey) ensureState().contacts.remove(publicKey) persistState(markWalletBackup = true) } - private suspend fun markContactsForProfileRecovery(publicKeys: Collection, startedAt: Long) { - val state = ensureState() - publicKeys.forEach { publicKey -> - cancelPendingPublicationRetry(publicKey) - activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } - activeHandlesByContact[publicKey]?.handshakeId?.let { - runCatching { pubkyService.dropEncryptedLinkHandshake(it) } - } - activeHandlesByContact[publicKey] = ContactPaykitHandles() - state.contacts[publicKey] = ContactState(recoveryStartedAt = startedAt) - } - } - - private suspend fun closeActiveHandles() { - activeHandlesByContact.values.forEach { handles -> - handles.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } - handles.handshakeId?.let { runCatching { pubkyService.dropEncryptedLinkHandshake(it) } } - } - } - - private suspend fun persistLinkSnapshot( - linkId: String, - publicKey: String, - linkWasReplaced: Boolean, - generation: Long = currentStateGeneration(), - ): Result = withContext(serializedDispatcher) { - runCatching { - ensureCurrentGeneration(generation) - if (activeHandlesByContact[publicKey]?.linkId != linkId) throw PrivatePaykitError.StaleLinkState - val snapshotHex = pubkyService.serializeEncryptedLink(linkId) - ensureCurrentGeneration(generation) - val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - val completedAttemptId = contactState.mainRecoveryAttemptId ?: contactState.responderRecoveryAttemptId - contactState.linkSnapshotHex = snapshotHex - contactState.handshakeSnapshotHex = null - contactState.recoveryStartedAt = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - if (completedAttemptId != null) { - contactState.lastCompletedRecoveryAttemptId = completedAttemptId - contactState.awaitingRecoveredRemoteEndpoints = true - } - if (linkWasReplaced || contactState.linkCompletedAt == null) { - contactState.linkCompletedAt = clock.now().epochSeconds - } - if (linkWasReplaced) { - contactState.lastLocalPayloadHash = null - } - persistState(markWalletBackup = true) - } - } - private suspend fun privatePayableEndpoints(endpoints: List, publicKey: String): List { val payable = publicPaykitRepo.payableEndpoints(endpoints) val attemptedHashes = attemptedOutboundBolt11PaymentHashes() @@ -1798,11 +893,9 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runCatching { - val ownPublicKey = pubkyService.currentPublicKey() ?: return@runCatching false - val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: return@runCatching false - val derivedPublicKey = pubkyService.publicKeyFromSecret(secretKeyHex) - PubkyPublicKeyFormat.matches(derivedPublicKey, ownPublicKey) + pubkyService.currentPublicKey() ?: return@runCatching false + val status = paykitSdkService.identityStatus() ?: return@runCatching false + status.privateLinkCapable }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = @@ -1812,13 +905,6 @@ class PrivatePaykitRepo @Inject constructor( cacheStore.update { it.copy(cleanupPending = isPending) } } - private suspend fun isProfileRecoveryPending(): Boolean = - cacheStore.data.first().profileRecoveryPending - - private suspend fun updateProfileRecoveryPending(isPending: Boolean) { - cacheStore.update { it.copy(profileRecoveryPending = isPending) } - } - private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = cacheStore.data.first().deletedContactCleanupPendingPublicKeys @@ -1843,6 +929,7 @@ class PrivatePaykitRepo @Inject constructor( updateDeletedContactCleanupPending(publicKey, false) return@forEach } + removePublishedEndpoints(publicKey).getOrThrow() clearContactState(publicKey) addressReservationRepo.clearContactAssignment(publicKey) @@ -1851,85 +938,6 @@ class PrivatePaykitRepo @Inject constructor( } } - private fun shouldRequirePrivateEndpointRemoval(publicKey: String): Boolean { - val contactState = stateStore.currentState()?.contacts?.get(publicKey) ?: return false - return contactState.linkSnapshotHex != null || - contactState.lastLocalPayloadHash != null || - contactState.localInvoice != null || - contactState.linkCompletedAt != null || - contactState.recoveryStartedAt != null - } - - private suspend fun shouldPublishLocalEndpoints(publicKey: String, fetchedRemoteCount: Int): Boolean { - val contactState = ensureState().contacts[publicKey] - if (contactState?.lastLocalPayloadHash != null) return true - if (fetchedRemoteCount > 0 || contactState?.remoteEndpoints?.isNotEmpty() == true) return true - val ownPublicKey = pubkyService.currentPublicKey() ?: return false - return shouldInitiate(ownPublicKey, publicKey) - } - - private suspend fun contactStateShouldPublishBeforeFetch(publicKey: String): Boolean { - if (!shouldPublishLocalEndpoints(publicKey, fetchedRemoteCount = 0)) return false - return !shouldDeferInitialLocalPublish(publicKey, fetchedRemoteCount = 0) - } - - private suspend fun shouldDeferInitialLocalPublish(publicKey: String, fetchedRemoteCount: Int): Boolean { - val contactState = ensureState().contacts[publicKey] ?: return false - val linkCompletedAt = contactState.linkCompletedAt ?: return false - return fetchedRemoteCount == 0 && - contactState.lastLocalPayloadHash == null && - contactState.remoteEndpoints.isEmpty() && - clock.now().epochSeconds <= linkCompletedAt + FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS - } - - @Suppress("ReturnCount") - private suspend fun shouldStartRecoveryHandshake(publicKey: String): Boolean { - val contactState = ensureState().contacts[publicKey] ?: return false - if (contactState.linkSnapshotHex != null) return false - if (contactState.recoveryStartedAt != null || contactState.mainRecoveryAttemptId != null) return true - if (contactState.handshakeSnapshotHex != null) return false - if (contactState.linkCompletedAt != null || contactState.handshakeUpdatedAt != null) return true - return addressReservationRepo.hasContactAssignment(publicKey) - } - - private suspend fun discardLinkForRecovery(publicKey: String, linkId: String?, startedAt: Long): Boolean { - linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } - activeHandlesByContact[publicKey] = ContactPaykitHandles() - ensureState().contacts[publicKey]?.apply { - linkSnapshotHex = null - handshakeSnapshotHex = null - lastLocalPayloadHash = null - remoteEndpoints = emptyList() - recoveryStartedAt = startedAt - mainRecoveryAttemptId = null - responderRecoveryAttemptId = null - awaitingRecoveredRemoteEndpoints = false - } - persistState(markWalletBackup = true) - return true - } - - private fun shouldAcceptRemoteRecoveryMarker( - remoteMarker: RecoveryMarker, - localMarker: RecoveryMarker?, - ownPublicKey: String, - remotePublicKey: String, - ): Boolean { - if (localMarker == null) return true - if (remoteMarker.createdAt != localMarker.createdAt) return remoteMarker.createdAt < localMarker.createdAt - if (remoteMarker.attemptId != localMarker.attemptId) return remoteMarker.attemptId < localMarker.attemptId - return remotePublicKey < ownPublicKey - } - - private fun isCompletedRecoveryMarker(marker: RecoveryMarker, publicKey: String): Boolean = - stateStore.currentState()?.contacts?.get(publicKey)?.lastCompletedRecoveryAttemptId == marker.attemptId - - private fun shouldReplaceUsableLink(marker: RecoveryMarker, publicKey: String): Boolean { - if (isCompletedRecoveryMarker(marker, publicKey)) return false - val linkCompletedAt = stateStore.currentState()?.contacts?.get(publicKey)?.linkCompletedAt ?: return true - return marker.createdAt > linkCompletedAt - } - private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() return ensureState().contacts.values.mapNotNull { it.localInvoice?.paymentHash?.takeIf(settled::contains) } @@ -1973,107 +981,12 @@ class PrivatePaykitRepo @Inject constructor( persistState() } - private suspend fun recordLinkFailure(publicKey: String, error: Throwable, generation: Long? = null) { - if (generation != null && stateGeneration.get() != generation) return - if (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) return - val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } - contactState.linkFailureCount += 1 - if (contactState.linkFailureCount < STALE_LINK_FAILURE_THRESHOLD) { - persistState() - return - } - - advanceStateGeneration() - activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } - activeHandlesByContact[publicKey] = ContactPaykitHandles() - contactState.linkSnapshotHex = null - contactState.handshakeSnapshotHex = null - contactState.lastLocalPayloadHash = null - contactState.remoteEndpoints = emptyList() - contactState.linkFailureCount = 0 - contactState.recoveryStartedAt = clock.now().epochSeconds - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - contactState.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup = true) - } - - private suspend fun recordLinkSuccess(publicKey: String) { - val contactState = ensureState().contacts[publicKey] ?: return - if (contactState.linkFailureCount == 0) return - contactState.linkFailureCount = 0 - persistState() - } - - private fun clearInvalidLinkSnapshotState(contactState: ContactState) { - contactState.linkSnapshotHex = null - contactState.handshakeSnapshotHex = null - contactState.remoteEndpoints = emptyList() - contactState.lastLocalPayloadHash = null - contactState.linkCompletedAt = null - contactState.handshakeUpdatedAt = null - contactState.recoveryStartedAt = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - contactState.awaitingRecoveredRemoteEndpoints = false - contactState.linkFailureCount = 0 - } - - private fun clearInvalidHandshakeSnapshotState(contactState: ContactState) { - contactState.handshakeSnapshotHex = null - contactState.lastLocalPayloadHash = null - contactState.handshakeUpdatedAt = null - contactState.recoveryStartedAt = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - contactState.linkFailureCount = 0 - } - - private suspend fun validatedSnapshot( - snapshotHex: String?, - publicKey: String, - recipient: suspend (String) -> String, - ): String? { - if (snapshotHex == null) return null - return runCatching { - validateSnapshot(snapshotHex, publicKey, recipient) - snapshotHex - }.onFailure { - Logger.warn( - "Dropped private Paykit snapshot with mismatched recipient for '${redacted(publicKey)}'", - it, - context = TAG, - ) - }.getOrNull() - } - - private suspend fun validateSnapshot( - snapshotHex: String, - publicKey: String, - recipient: suspend (String) -> String, - ) { - if (!snapshotRecipientMatches(snapshotHex, publicKey, recipient)) { - throw PrivatePaykitError.SnapshotRecipientMismatch - } - } - - private suspend fun snapshotRecipientMatches( - snapshotHex: String, - publicKey: String, - recipient: suspend (String) -> String, - ): Boolean { - val snapshotRecipient = recipient(snapshotHex) - return PubkyPublicKeyFormat.normalized(snapshotRecipient) == PubkyPublicKeyFormat.normalized(publicKey) - } - private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() if (replacing) { knownSavedContactKeys.clear() - knownSavedContactKeys += normalizedKeys - } else { - knownSavedContactKeys += normalizedKeys } + knownSavedContactKeys.addAll(normalizedKeys) return normalizedKeys } @@ -2086,13 +999,28 @@ class PrivatePaykitRepo @Inject constructor( private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) - private suspend fun ensureState(): PrivatePaykitState = stateStore.ensureState() + private suspend fun ensureState(): PrivatePaykitState { + state?.let { return it } + return PrivatePaykitState(cacheStore.data.first()).also { state = it } + } private suspend fun persistState( markWalletBackup: Boolean = false, preserveCleanupMarkers: Boolean = true, ) { - stateStore.persistState(markWalletBackup, ::notifyBackupStateChanged, preserveCleanupMarkers) + val currentState = state ?: PrivatePaykitState() + val stored = cacheStore.data.first() + cacheStore.update { + currentState.cacheState( + cleanupPending = if (preserveCleanupMarkers) stored.cleanupPending else false, + deletedContactCleanupPendingPublicKeys = if (preserveCleanupMarkers) { + stored.deletedContactCleanupPendingPublicKeys + } else { + emptySet() + }, + ) + } + if (markWalletBackup) notifyBackupStateChanged() } private fun notifyBackupStateChanged() { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt deleted file mode 100644 index bc58102f99..0000000000 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt +++ /dev/null @@ -1,75 +0,0 @@ -package to.bitkit.repositories - -import kotlinx.coroutines.flow.first -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import to.bitkit.data.PrivatePaykitCacheStore -import to.bitkit.data.keychain.Keychain -import to.bitkit.di.json -import to.bitkit.utils.Logger - -internal class PrivatePaykitStateStore( - private val keychain: Keychain, - private val cacheStore: PrivatePaykitCacheStore, -) { - companion object { - private const val TAG = "PrivatePaykitStateStore" - } - - private var state: PrivatePaykitState? = null - - fun currentState(): PrivatePaykitState? = state - - fun replaceState(newState: PrivatePaykitState) { - state = newState - } - - suspend fun ensureState(): PrivatePaykitState { - state?.let { return it } - val serializedSecretState = runCatching { - keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) - }.onFailure { - Logger.warn("Failed to load private Paykit secret state", it, context = TAG) - }.getOrNull() - val secretState = serializedSecretState - ?.let { serialized -> - runCatching { - json.decodeFromString(serialized) - }.onFailure { - Logger.warn("Failed to decode private Paykit secret state", it, context = TAG) - }.getOrNull() - } ?: PrivatePaykitSecretState() - val cacheState = cacheStore.data.first() - - return PrivatePaykitState(secretState, cacheState).also { state = it } - } - - suspend fun persistState( - markWalletBackup: Boolean, - notifyBackupStateChanged: () -> Unit, - preserveCleanupMarkers: Boolean = true, - ) { - val current = state ?: return - runCatching { - val secretState = current.secretState() - if (secretState.contacts.isEmpty()) { - keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) - } else { - keychain.upsertString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name, json.encodeToString(secretState)) - } - - cacheStore.update { stored -> - current.cacheState( - cleanupPending = if (preserveCleanupMarkers) stored.cleanupPending else false, - deletedContactCleanupPendingPublicKeys = if (preserveCleanupMarkers) { - stored.deletedContactCleanupPendingPublicKeys - } else { - emptySet() - }, - profileRecoveryPending = if (preserveCleanupMarkers) stored.profileRecoveryPending else false, - ) - } - if (markWalletBackup) notifyBackupStateChanged() - }.getOrElse { throw PrivatePaykitError.StatePersistenceFailed(it) } - } -} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 48b61bdeb2..507570daea 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,16 +3,19 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader -import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.ContactProfileResolution +import com.synonym.paykit.PaykitProfile import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -28,7 +31,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.hasPublicPaykitPublicationState @@ -77,7 +79,6 @@ class PubkyRepo @Inject constructor( private val imageLoader: ImageLoader, private val pubkyStore: PubkyStore, private val settingsStore: SettingsStore, - private val privatePaykitCacheStore: PrivatePaykitCacheStore, private val httpClient: HttpClient, ) { companion object { @@ -237,10 +238,9 @@ class PubkyRepo @Inject constructor( } } else { runCatching { - val newSession = pubkyService.signIn(storedSecretKeyHex) - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) + pubkyService.signIn(storedSecretKeyHex) notifyBackupStateChanged() - val publicKey = pubkyService.importSession(newSession).ensurePubkyPrefix() + val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() Logger.info("Re-signed in and restored session for '$publicKey'", context = TAG) InitResult.Restored(publicKey) }.getOrElse { @@ -275,15 +275,14 @@ class PubkyRepo @Inject constructor( val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) return runCatching { withContext(ioDispatcher) { - val sessionSecret = pubkyService.completeAuth() + pubkyService.completeAuth() ensureAuthAttemptActive(attemptId) - val pk = pubkyService.importSession(sessionSecret).ensurePubkyPrefix() + val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { + "No active Pubky session" + } ensureAuthAttemptActive(attemptId) - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - privatePaykitCacheStore.update { it.copy(profileRecoveryPending = false) } notifyBackupStateChanged() pk @@ -389,37 +388,10 @@ class PubkyRepo @Inject constructor( // region Payment endpoints - suspend fun getPaymentList(publicKey: String): Result> = withContext(ioDispatcher) { - runCatching { - pubkyService.getPaymentList(publicKey.ensurePubkyPrefix()) - } - } - - suspend fun setPaymentEndpoint(methodId: String, endpointData: String): Result = withContext(ioDispatcher) { - runCatching { - pubkyService.setPaymentEndpoint(methodId, endpointData) - } - } - - suspend fun removePaymentEndpoint(methodId: String): Result = withContext(ioDispatcher) { - runCatching { - pubkyService.removePaymentEndpoint(methodId) - } - } - suspend fun removeBitkitPaymentEndpoints(): Result = withContext(ioDispatcher) { runCatching { - val currentPublicKey = _publicKey.value ?: pubkyService.currentPublicKey()?.ensurePubkyPrefix() - ?: return@runCatching - val managedMethodIds = MethodId.entries - .filter { it.isBitkitManaged } - .map { it.rawValue } - .toSet() - - pubkyService.getPaymentList(currentPublicKey) - .map { it.methodId } - .filter { it in managedMethodIds } - .forEach { pubkyService.removePaymentEndpoint(it) } + pubkyService.removeBitkitPaymentEndpoints() + Unit } } @@ -441,10 +413,8 @@ class PubkyRepo @Inject constructor( try { runCatching { withContext(ioDispatcher) { - fetchBitkitProfile(pk) ?: run { - val ffiProfile = pubkyService.getProfile(pk) - PubkyProfile.fromFfi(pk, ffiProfile) - } + resolveContactProfile(pk).getOrThrow() + ?: throw AppError("Profile not found") } }.onSuccess { loadedProfile -> if (_publicKey.value != pk) { @@ -462,21 +432,9 @@ class PubkyRepo @Inject constructor( } } - private suspend fun fetchBitkitProfile(publicKey: String): PubkyProfile? = runCatching { - val strippedKey = publicKey.removePrefix(PUBKY_PREFIX) - val uri = "$PUBKY_SCHEME$strippedKey${Env.profilePath}" - val json = pubkyService.fetchFileString(uri) - PubkyProfileData.decode(json).toPubkyProfile(publicKey) - }.onFailure { - Logger.debug("Falling back to FFI, no bitkit profile found", context = TAG) - }.getOrNull() - suspend fun fetchRemoteProfile(publicKey: String): Result = runCatching { withContext(ioDispatcher) { - fetchBitkitProfile(publicKey) ?: run { - val ffiProfile = pubkyService.getProfile(publicKey) - PubkyProfile.fromFfi(publicKey, ffiProfile) - } + resolveContactProfile(publicKey).getOrThrow() } } @@ -508,56 +466,42 @@ class PubkyRepo @Inject constructor( val homegate = fetchHomegateSignupCode() - val session = runCatching { + runCatching { pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) }.getOrElse { Logger.warn("Retrying sign in after sign up failed", it, context = TAG) pubkyService.signIn(secretKeyHex) } - keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, session) - notifyBackupStateChanged() - pubkyService.importSession(session) - - val imageUrl = avatarBytes?.let { uploadAvatar(it, secretKeyHex).getOrNull() } - writeProfile(session, name, bio, links, tags, imageUrl) + val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } + writeProfile(name, bio, links, tags, imageUrl) + val createdProfile = PubkyProfile( + publicKey = publicKeyZ32, + name = name, + bio = bio, + imageUrl = imageUrl, + links = links, + tags = tags, + status = null, + ) _publicKey.update { publicKeyZ32 } _authState.update { PubkyAuthState.Authenticated } + _profile.update { createdProfile } + cacheMetadata(createdProfile) Logger.info("Created identity for '$publicKeyZ32'", context = TAG) loadProfile() loadContacts() } } - suspend fun uploadAvatar(imageBytes: ByteArray, secretKeyHex: String): Result = runCatching { - withContext(ioDispatcher) { - val compressed = compressAvatar(imageBytes) - val path = "${Env.blobsBasePath}${System.currentTimeMillis()}.jpg" - pubkyService.putWithSecretKey(secretKeyHex, path, compressed) - val strippedKey = pubkyService.publicKeyFromSecret(secretKeyHex).removePrefix(PUBKY_PREFIX) - "$PUBKY_SCHEME$strippedKey$path" - } - } - suspend fun uploadAvatar(imageBytes: ByteArray): Result = runCatching { withContext(ioDispatcher) { - val publicKey = requireNotNull(_publicKey.value) { - "No public key available" - } - val secretKeyHex = managedSecretKeyFor(publicKey) - if (secretKeyHex != null) { - return@withContext uploadAvatar(imageBytes, secretKeyHex).getOrThrow() - } - - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { + requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } val compressed = compressAvatar(imageBytes) - val path = "${Env.blobsBasePath}${System.currentTimeMillis()}.jpg" - pubkyService.sessionPut(session, path, compressed) - "$PUBKY_SCHEME${publicKey.removePrefix(PUBKY_PREFIX)}$path" + pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg") } } @@ -569,10 +513,10 @@ class PubkyRepo @Inject constructor( imageUrl: String?, ): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { + requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } - writeProfile(session, name, bio, links, tags, imageUrl) + writeProfile(name, bio, links, tags, imageUrl) val pk = requireNotNull(_publicKey.value) { "No public key available" } val profile = PubkyProfile( publicKey = pk, @@ -600,12 +544,12 @@ class PubkyRepo @Inject constructor( suspend fun deleteProfile(): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { + requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } - deleteAllContacts(session) + deleteAllContacts() runCatching { - pubkyService.sessionDelete(session, Env.profilePath) + pubkyService.deletePaykitProfile() }.getOrElse { if (!it.isMissingPubkyData()) { throw it @@ -616,21 +560,22 @@ class PubkyRepo @Inject constructor( signOut().getOrThrow() } - private suspend fun deleteAllContacts(session: String) { - val contactPaths = runCatching { - pubkyService.sessionList(session, Env.contactsBasePath) + private suspend fun deleteAllContacts() { + val records = runCatching { + pubkyService.contactRecords() }.getOrElse { - if (it.isMissingPubkyDirectory()) return - throw it + if (!it.isMissingPubkyData()) throw it + emptyList() } - contactPaths.forEach { path -> - val contactKey = path.substringAfterLast("/") + records.forEach { record -> runCatching { - pubkyService.sessionDelete(session, "${Env.contactsBasePath}$contactKey") + pubkyService.removeContact(record.publicKey) }.onFailure { - Logger.warn("Failed to delete contact '$contactKey'", it, context = TAG) + Logger.warn("Failed to delete contact '${record.publicKey}'", it, context = TAG) } } + pubkyStore.update { it.copy(contactProfileOverrides = emptyMap()) } + notifyBackupStateChanged() _contacts.update { emptyList() } markContactsLoaded() Logger.info("Deleted all contacts", context = TAG) @@ -638,7 +583,6 @@ class PubkyRepo @Inject constructor( @Suppress("LongParameterList") private suspend fun writeProfile( - sessionSecret: String, name: String, bio: String, links: List, @@ -654,7 +598,7 @@ class PubkyRepo @Inject constructor( tags = tags, status = null, ).toProfileData() - pubkyService.sessionPut(sessionSecret, Env.profilePath, data.encode()) + pubkyService.publishPaykitProfile(data.toPaykitProfile()) } private fun compressAvatar(imageBytes: ByteArray): ByteArray { @@ -688,35 +632,18 @@ class PubkyRepo @Inject constructor( try { runCatching { withContext(ioDispatcher) { - val session = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - ?: return@withContext emptyList() - - val contactPaths = runCatching { - pubkyService.sessionList(session, Env.contactsBasePath) - }.getOrElse { - if (it.isMissingPubkyDirectory()) { - Logger.debug( - "Treating missing contacts directory as empty for '$pk'", - context = TAG, - ) - return@withContext emptyList() - } - throw it - } - val strippedOwnerKey = pk.removePrefix(PUBKY_PREFIX) + val records = pubkyService.contactRecords() + val overrides = pubkyStore.data.first().contactProfileOverrides coroutineScope { - contactPaths.map { path -> - val contactKey = path.substringAfterLast("/") + records.map { record -> async { runCatching { - val uri = "$PUBKY_SCHEME$strippedOwnerKey${Env.contactsBasePath}$contactKey" - val json = pubkyService.fetchFileString(uri) - PubkyProfileData.decode(json).toPubkyProfile(contactKey) + contactProfile(record.publicKey, record.label, record.profile, overrides) }.onFailure { - Logger.warn("Failed to load contact '$contactKey'", it, context = TAG) + Logger.warn("Failed to load contact '${record.publicKey}'", it, context = TAG) }.getOrElse { - PubkyProfile.placeholder(contactKey) + PubkyProfile.placeholder(record.publicKey.ensurePubkyPrefix()) } } }.awaitAll().sortedBy { it.name.lowercase() } @@ -741,10 +668,10 @@ class PubkyRepo @Inject constructor( suspend fun fetchContactProfile(publicKey: String): Result { val prefixedKey = runCatching { requireAddableContactPublicKey(publicKey) } .getOrElse { return Result.failure(it) } - return fetchRemoteProfile(prefixedKey) + return resolveContactProfile(prefixedKey) .map { it ?: PubkyProfile.placeholder(prefixedKey) } .recoverCatching { - if (!it.isMissingPubkyData()) { + if (it is CancellationException) { throw it } Logger.warn("Falling back to placeholder contact '$prefixedKey'", it, context = TAG) @@ -754,19 +681,14 @@ class PubkyRepo @Inject constructor( suspend fun addContact(publicKey: String, existingProfile: PubkyProfile? = null): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" - } val prefixedKey = requireAddableContactPublicKey( publicKey = publicKey, allowExisting = existingProfile != null, ) - val profile = existingProfile?.copy(publicKey = prefixedKey) ?: run { - val ffiProfile = pubkyService.getProfile(prefixedKey) - PubkyProfile.fromFfi(prefixedKey, ffiProfile) - } - val data = profile.toProfileData().encode() - pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data) + val profile = existingProfile?.copy(publicKey = prefixedKey) + ?: resolveContactProfile(prefixedKey).getOrThrow() + ?: PubkyProfile.placeholder(prefixedKey) + pubkyService.saveContact(prefixedKey, profile.name) _contacts.update { current -> (current.filter { it.publicKey != prefixedKey } + profile) .sortedBy { it.name.lowercase() } @@ -786,9 +708,6 @@ class PubkyRepo @Inject constructor( tags: List, ): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" - } val prefixedKey = publicKey.ensurePubkyPrefix() val updatedProfile = PubkyProfile( publicKey = prefixedKey, @@ -799,8 +718,8 @@ class PubkyRepo @Inject constructor( tags = tags, status = null, ) - val data = updatedProfile.toProfileData().encode() - pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data) + pubkyService.saveContact(prefixedKey, name) + upsertContactProfileOverride(updatedProfile) _contacts.update { current -> current.map { if (it.publicKey == prefixedKey) updatedProfile else it } .sortedBy { it.name.lowercase() } @@ -812,11 +731,9 @@ class PubkyRepo @Inject constructor( suspend fun removeContact(publicKey: String): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" - } val prefixedKey = publicKey.ensurePubkyPrefix() - pubkyService.sessionDelete(session, "${Env.contactsBasePath}$prefixedKey") + pubkyService.removeContact(prefixedKey) + removeContactProfileOverride(prefixedKey) _contacts.update { current -> current.filter { it.publicKey != prefixedKey } } markContactsLoaded() Logger.info("Removed contact '$prefixedKey'", context = TAG) @@ -825,18 +742,14 @@ class PubkyRepo @Inject constructor( suspend fun importContacts(publicKeys: List): Result = runCatching { withContext(ioDispatcher) { - val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" - } val imported = coroutineScope { publicKeys.map { contactPk -> val prefixedKey = contactPk.ensurePubkyPrefix() async { runCatching { - val ffiProfile = pubkyService.getProfile(prefixedKey) - val profile = PubkyProfile.fromFfi(prefixedKey, ffiProfile) - val data = profile.toProfileData().encode() - pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data) + val profile = resolveContactProfile(prefixedKey).getOrThrow() + ?: PubkyProfile.placeholder(prefixedKey) + pubkyService.saveContact(prefixedKey, profile.name) profile }.onFailure { Logger.warn("Failed to import contact '$prefixedKey'", it, context = TAG) @@ -866,17 +779,13 @@ class PubkyRepo @Inject constructor( val prefixedKey = contactPk.ensurePubkyPrefix() async { runCatching { - val ffiProfile = pubkyService.getProfile(prefixedKey) - PubkyProfile.fromFfi(prefixedKey, ffiProfile) + resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) }.getOrElse { PubkyProfile.placeholder(prefixedKey) } } }.awaitAll().sortedBy { it.name.lowercase() } } - val ownProfile = runCatching { - val ffiProfile = pubkyService.getProfile(pk) - PubkyProfile.fromFfi(pk, ffiProfile) - }.getOrNull() + val ownProfile = resolveContactProfile(pk).getOrNull() _pendingImportProfile.update { ownProfile } _pendingImportContacts.update { contacts } @@ -929,32 +838,40 @@ class PubkyRepo @Inject constructor( } } - suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { + suspend fun snapshotContactProfileOverrides(): Result?> = runCatching { withContext(ioDispatcher) { - if (backup == null) { - notifyBackupStateChanged() - return@withContext - } + pubkyStore.data.first().contactProfileOverrides.takeUnless { it.isEmpty() } + } + } + suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { + withContext(ioDispatcher) { ensureServiceInitialized() initializeMutex.withLock { - pubkyService.forceSignOut() + pubkyService.clearSessionAccess() clearAuthenticatedState() runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - when (backup.kind) { + when (backup?.kind) { + null -> Unit + PubkySessionBackupKind.LocalSeed -> { val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() - keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) + pubkyService.signIn(secretKeyHex) + val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } } PubkySessionBackupKind.ExternalSession -> { val sessionSecret = requireNotNull(backup.sessionSecret?.takeIf { it.isNotBlank() }) { "Missing session secret in backup" } - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) + val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } } } @@ -963,15 +880,23 @@ class PubkyRepo @Inject constructor( } } + suspend fun restoreContactProfileOverrides(overrides: Map?): Result = runCatching { + withContext(ioDispatcher) { + pubkyStore.update { + it.copy(contactProfileOverrides = overrides ?: emptyMap()) + } + notifyBackupStateChanged() + } + } + suspend fun refreshSessionIfPossible(): Result = runCatching { withContext(ioDispatcher) { val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext false - val sessionSecret = pubkyService.signIn(storedSecretKeyHex) - val publicKey = pubkyService.importSession(sessionSecret).ensurePubkyPrefix() + pubkyService.signIn(storedSecretKeyHex) + val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() - keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) notifyBackupStateChanged() _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } @@ -1021,6 +946,91 @@ class PubkyRepo @Inject constructor( } } + private suspend fun contactProfile( + publicKey: String, + label: String?, + paykitProfile: PaykitProfile?, + overrides: Map, + ): PubkyProfile { + val prefixedKey = publicKey.ensurePubkyPrefix() + overrides[prefixedKey]?.let { + return it.toPubkyProfile(prefixedKey) + } + paykitProfile?.let { + return PubkyProfile.fromPaykitProfile(prefixedKey, it) + } + resolveContactProfile(prefixedKey).getOrNull()?.let { + return it + } + return PubkyProfile.forDisplay( + publicKey = prefixedKey, + name = label, + imageUrl = null, + ) + } + + private suspend fun resolveContactProfile(publicKey: String): Result = runCatching { + withContext(ioDispatcher) { + val prefixedKey = publicKey.ensurePubkyPrefix() + var lastError: Throwable? = null + + repeat(2) { attempt -> + val result = runCatching { + val profile = pubkyService.resolveContactProfile( + publicKey = prefixedKey, + allowPubkyProfileFallback = true, + )?.let(::profileFromResolution) + if (profile != null || attempt == 1) { + return@withContext profile + } + } + result.exceptionOrNull()?.let { error -> + if (error is CancellationException) throw error + lastError = error + } + + if (attempt == 0) { + Logger.warn("Retrying contact profile resolution for '$prefixedKey'", lastError, context = TAG) + delay(250) + } + } + + lastError?.let { throw it } + null + } + } + + private fun profileFromResolution(resolution: ContactProfileResolution): PubkyProfile { + val prefixedKey = resolution.publicKey.ensurePubkyPrefix() + resolution.paykitProfile?.let { + return PubkyProfile.fromPaykitProfile(prefixedKey, it) + } + resolution.pubkyProfile?.let { + return PubkyProfile.fromPubkyProfile(prefixedKey, it) + } + return PubkyProfile.forDisplay( + publicKey = prefixedKey, + name = resolution.displayName, + imageUrl = resolution.imageUri, + ) + } + + private suspend fun upsertContactProfileOverride(profile: PubkyProfile) { + val prefixedKey = profile.publicKey.ensurePubkyPrefix() + pubkyStore.update { data -> + data.copy(contactProfileOverrides = data.contactProfileOverrides + (prefixedKey to profile.toProfileData())) + } + notifyBackupStateChanged() + } + + private suspend fun removeContactProfileOverride(publicKey: String) { + val prefixedKey = publicKey.ensurePubkyPrefix() + pubkyStore.update { data -> + data.copy(contactProfileOverrides = data.contactProfileOverrides - prefixedKey) + } + notifyBackupStateChanged() + } + private suspend fun cacheMetadata(profile: PubkyProfile) { pubkyStore.update { it.copy(cachedName = profile.name, cachedImageUri = profile.imageUrl) @@ -1117,15 +1127,6 @@ class PubkyRepo @Inject constructor( private fun String.ensurePubkyPrefix(): String = if (startsWith(PUBKY_PREFIX)) this else "$PUBKY_PREFIX$this" - private fun Throwable.isMissingPubkyDirectory(): Boolean { - if (isMissingPubkyData()) { - return true - } - - val fullMessage = buildErrorMessage() - return fullMessage.contains("directory not found", ignoreCase = true) - } - private fun Throwable.isMissingPubkyData(): Boolean { val fullMessage = buildErrorMessage() return fullMessage.contains("404") || diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 3e7e41ff0c..d10f6792e7 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -22,6 +22,7 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService +import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.encodeToUrl @@ -40,6 +41,7 @@ sealed class PublicPaykitError(message: String) : AppError(message) { data object RouteHintsUnavailable : PublicPaykitError("Reachable Lightning payment endpoint is not available yet") data object SessionNotActive : PublicPaykitError("No active Paykit session") data object WalletNotReady : PublicPaykitError("Wallet is not ready to publish Paykit endpoints") + data object PublicationFailed : PublicPaykitError("Failed to publish Paykit payment endpoints") } sealed interface PublicPaykitPaymentResult { @@ -57,6 +59,7 @@ class PublicPaykitRepo @Inject constructor( private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, private val coreService: CoreService, + private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, private val clock: Clock, ) { @@ -69,7 +72,7 @@ class PublicPaykitRepo @Inject constructor( encodeDefaults = false } - private val payablePreferenceOrder = listOf( + internal val payablePreferenceOrder = listOf( MethodId.Bolt11, MethodId.Lnurl, MethodId.P2tr, @@ -78,7 +81,6 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2pkh, ) - private val managedMethodIds = MethodId.entries.filter { it.isBitkitManaged } private val publicBolt11Expiry = 24.hours private val publicBolt11RefreshWindow = 30.minutes @@ -219,8 +221,8 @@ class PublicPaykitRepo @Inject constructor( private suspend fun fetchPublicEndpoints(publicKey: String): Result> = withContext(ioDispatcher) { runCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey - pubkyRepo.getPaymentList(normalizedKey).getOrThrow() - .mapNotNull { parseEndpoint(it.methodId, it.endpointData) } + paykitSdkService.resolvePublicContactPayment(counterparty = normalizedKey).payableEndpoints + .mapNotNull { parseEndpoint(it.identifier, it.payload) } .associateBy { it.methodId } .values .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } @@ -228,37 +230,18 @@ class PublicPaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints() { - publishMutex.withLock { - val currentMethodIds = currentPublishedMethodIds() - managedMethodIds - .filter { it.rawValue in currentMethodIds } - .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } - clearPublicBolt11Metadata() - } + applyPublishedEndpoints(emptyList()) + clearPublicBolt11Metadata() } private suspend fun applyPublishedEndpoints(desiredEndpoints: List) { publishMutex.withLock { requireCurrentPublicKey() - val desiredMethodIds = desiredEndpoints.map { it.methodId.rawValue }.toSet() - - desiredEndpoints.forEach { - pubkyRepo.setPaymentEndpoint(it.methodId.rawValue, it.rawPayload).getOrThrow() - } - - val publishedMethodIds = currentPublishedMethodIds() - managedMethodIds - .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } - .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + val report = paykitSdkService.syncPublicEndpoints(desiredEndpoints) + if (report.failed.isNotEmpty()) throw PublicPaykitError.PublicationFailed } } - private suspend fun currentPublishedMethodIds(): Set { - return pubkyRepo.getPaymentList(requireCurrentPublicKey()).getOrThrow() - .map { it.methodId } - .toSet() - } - private suspend fun requireCurrentPublicKey(): String { val currentPublicKey = pubkyRepo.publicKey.value ?: pubkyRepo.currentPublicKey().getOrThrow() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt new file mode 100644 index 0000000000..a4d3c5be5d --- /dev/null +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -0,0 +1,709 @@ +package to.bitkit.services + +import android.content.Context +import com.synonym.paykit.ContactPaymentResolution +import com.synonym.paykit.ContactProfileResolution +import com.synonym.paykit.ContactRecord +import com.synonym.paykit.ContactUpdate +import com.synonym.paykit.EndpointSyncReport +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaykitAndroid +import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitProfileRecord +import com.synonym.paykit.PaykitSdk +import com.synonym.paykit.PaykitSdkDefaults +import com.synonym.paykit.PaymentEndpointCandidate +import com.synonym.paykit.PaymentEndpointReservationCancellation +import com.synonym.paykit.PaymentEndpointSelectionRequest +import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PaymentPayload +import com.synonym.paykit.PaymentTarget +import com.synonym.paykit.PrivatePaymentListDeliveryReport +import com.synonym.paykit.PrivatePaymentListReservationUpdateInput +import com.synonym.paykit.PubkyAuthRequest +import com.synonym.paykit.PubkyLocalSecretKey +import com.synonym.paykit.PubkyProfile +import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.PubkySessionBootstrap +import com.synonym.paykit.PubkySessionBootstrapResult +import com.synonym.paykit.ReceivingDetail +import com.synonym.paykit.ReceivingDetailReservationResponse +import com.synonym.paykit.ReceivingDetailReservationResponseKind +import com.synonym.paykit.ReceivingDetailScope +import com.synonym.paykit.SdkPaymentAdapter +import com.synonym.paykit.SdkPubkySessionProvider +import com.synonym.paykit.SdkStateBlob +import com.synonym.paykit.SdkStateBlobSnapshot +import com.synonym.paykit.SdkStateBlobStore +import com.synonym.paykit.decodeSdkStateBlobSnapshot +import com.synonym.paykit.defaultConfig +import com.synonym.paykit.derivePubkySecretKey +import com.synonym.paykit.encodeSdkStateBlobSnapshot +import com.synonym.paykit.parsePubkyAuthUrl +import com.synonym.paykit.pubkyPublicKeyFromSecret +import com.synonym.paykit.requiredSessionCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.lightningdevkit.ldknode.Network +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.Endpoint +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.utils.AppError +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +data class PaykitContactPaymentResolution( + val payableEndpoints: List, +) + +data class PaykitResolvedPaymentEndpoint( + val counterparty: String, + val source: PaymentEndpointSource, + val identifier: String, + val payload: String, +) + +@Singleton +@Suppress("TooManyFunctions") +class PaykitSdkService @Inject constructor( + @ApplicationContext private val context: Context, + private val keychain: Keychain, +) { + private val stateStore = PaykitSdkStateBlobStore(keychain) + private val sessionProvider = PaykitSdkSessionProvider(keychain) + private val paymentAdapter = PaykitSdkPaymentAdapter() + private val handleMutex = Mutex() + private val operationMutex = Mutex() + private val setupMutex = Mutex() + private var isSetup = CompletableDeferred() + private var setupFailed = false + private var sdk: PaykitSdk? = null + private var activeAuthRequest: PubkyAuthRequest? = null + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + + @Suppress("TooGenericExceptionCaught") + suspend fun initialize() { + setupMutex.withLock { + if (isSetup.isCompleted && !setupFailed) return + if (setupFailed) { + isSetup = CompletableDeferred() + setupFailed = false + } + + try { + PaykitAndroid.initializeOrThrow(context) + operationMutex.withLock { + handle().initialize() + } + isSetup.complete(Unit) + } catch (t: Throwable) { + setupFailed = true + isSetup.completeExceptionally(t) + throw t + } + } + } + + suspend fun currentPublicKey(): String? { + isSetup.await() + return operationMutex.withLock { + val handle = handle() + handle.identityStatus()?.publicKey?.let { return@withLock it } + handle.initialize().identity.publicKey + } + } + + suspend fun identityStatus(): IdentityStatus? { + isSetup.await() + return operationMutex.withLock { + handle().identityStatus() + } + } + + suspend fun importSession( + secret: String, + includeLocalSecret: Boolean = true, + ): PubkySessionBootstrapResult { + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val result = PubkySessionBootstrap().importSession( + sessionSecret = secret, + localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, + requiredCapabilities = requiredSessionCapabilities(paykitSdkConfig()), + ) + operationMutex.withLock { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = includeLocalSecret, + ) + } + notifyBackupStateChanged() + return result + } + + suspend fun signUp( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String?, + ): PubkySessionBootstrapResult { + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val result = PubkySessionBootstrap().signUp( + localSecretKey = localSecretKey(secretKeyHex), + homeserverPublicKey = homeserverPublicKey, + signupCode = signupCode, + ) + operationMutex.withLock { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + } + notifyBackupStateChanged() + return result + } + + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + val result = PubkySessionBootstrap().signIn(localSecretKey(secretKeyHex)) + operationMutex.withLock { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + } + notifyBackupStateChanged() + return result + } + + suspend fun startAuth(): String { + isSetup.await() + return operationMutex.withLock { + val request = PubkySessionBootstrap().startSignInAuth(requiredCapabilities()) + activeAuthRequest = request + request.authorizationUrl() + } + } + + suspend fun completeAuth(): PubkySessionBootstrapResult { + isSetup.await() + return operationMutex.withLock { + val request = requireNotNull(activeAuthRequest) { "No active Pubky auth request" } + val previousPublicKey = currentSdkStatePublicKeyLocked() + var completed = false + try { + request.complete( + localSecretKey = null, + requiredCapabilities = requiredCapabilities(), + ).also { + activateBootstrapResult( + result = it, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = false, + ) + notifyBackupStateChanged() + completed = true + } + } finally { + activeAuthRequest = null + if (!completed) resetRuntime() + } + } + } + + suspend fun cancelAuth() { + isSetup.await() + operationMutex.withLock { + activeAuthRequest = null + } + } + + suspend fun approveAuth(authUrl: String, secretKeyHex: String) { + isSetup.await() + PubkySessionBootstrap().approveAuth( + authUrl = authUrl, + expectedCapabilities = requiredCapabilities(), + localSecretKey = localSecretKey(secretKeyHex), + ) + } + + suspend fun fetchFile(uri: String): ByteArray { + isSetup.await() + return operationMutex.withLock { + handle().fetchPubkyFile(uri) ?: throw AppError("Pubky file not found") + } + } + + suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { + isSetup.await() + return operationMutex.withLock { + handle().publishPaykitProfile(profile).also { + notifyBackupStateChanged() + } + } + } + + suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String { + isSetup.await() + return operationMutex.withLock { + handle().uploadProfileAvatar(bytes, contentType).uri.also { + notifyBackupStateChanged() + } + } + } + + suspend fun deletePaykitProfile() { + isSetup.await() + operationMutex.withLock { + handle().deletePaykitProfile() + notifyBackupStateChanged() + } + } + + suspend fun fetchPubkyProfile(publicKey: String): PubkyProfile? { + isSetup.await() + return operationMutex.withLock { + handle().fetchPubkyProfile(publicKey)?.profile + } + } + + suspend fun fetchPubkyFollows(publicKey: String): List { + isSetup.await() + return operationMutex.withLock { + handle().fetchPubkyFollows(publicKey) + } + } + + suspend fun contactRecords(): List { + isSetup.await() + return operationMutex.withLock { + handle().contactRecords() + } + } + + suspend fun saveContact(publicKey: String, label: String?): ContactRecord { + isSetup.await() + return operationMutex.withLock { + handle().saveContact(ContactUpdate(publicKey, label)).also { + notifyBackupStateChanged() + } + } + } + + suspend fun removeContact(publicKey: String): ContactRecord? { + isSetup.await() + return operationMutex.withLock { + handle().removeContact(publicKey).also { + notifyBackupStateChanged() + } + } + } + + suspend fun resolveContactProfile( + publicKey: String, + allowPubkyProfileFallback: Boolean, + ): ContactProfileResolution? { + isSetup.await() + return operationMutex.withLock { + handle().resolveContactProfile(publicKey, allowPubkyProfileFallback) + } + } + + suspend fun syncPublicEndpoints(endpoints: List): EndpointSyncReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toReceivingDetail() }) + } + } + } + + fun requiredCapabilities(): String = requiredSessionCapabilities(paykitSdkConfig()) + + suspend fun syncPrivatePaymentListsWithReservations( + updates: List, + clearUnlistedLinkedPeers: Boolean, + ): PrivatePaymentListDeliveryReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates = updates, + clearUnlistedLinkedPeers = clearUnlistedLinkedPeers, + ) + } + } + } + + suspend fun ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt = 8u) = run { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.ensureLinkWithPeer(counterparty, maxAdvanceSteps) + } + } + } + + suspend fun clearPrivatePaymentList(counterparty: String): PrivatePaymentListDeliveryReport { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.clearPrivatePaymentListAndProcessOutbound(counterparty) + } + } + } + + suspend fun receivePrivateMessagesFromLinkedPeers() { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.receivePrivateMessagesFromLinkedPeers() + } + } + } + + suspend fun processPendingPrivateMessages() { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.processPendingPrivateMessages() + } + } + } + + suspend fun prepareAndResolveContactPayment( + counterparty: String, + includePublicEndpoints: Boolean, + ): PaykitContactPaymentResolution { + isSetup.await() + val prepared = operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.prepareAndResolveContactPayment( + counterparty = counterparty, + amount = null, + includePublicEndpoints = includePublicEndpoints, + maxAdvanceSteps = 8u, + ) + } + } + return prepared.resolution.toPaykitContactPaymentResolution() + } + + suspend fun resolvePublicContactPayment(counterparty: String): PaykitContactPaymentResolution { + isSetup.await() + val resolution = operationMutex.withLock { + handle().resolvePublicContactPayment(counterparty, amount = null) + } + return resolution.toPaykitContactPaymentResolution() + } + + private fun ContactPaymentResolution.toPaykitContactPaymentResolution(): PaykitContactPaymentResolution { + return PaykitContactPaymentResolution( + payableEndpoints = payableEndpoints.map { + PaykitResolvedPaymentEndpoint( + counterparty = it.counterparty, + source = it.source, + identifier = it.identifier, + payload = it.target.payload.exportText(), + ) + }, + ) + } + + suspend fun exportBackupState(): String { + isSetup.await() + return operationMutex.withLock { + handle().exportBackupString() + } + } + + suspend fun restoreBackupState(backup: String) { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.restoreBackupString(backup) + } + resetRuntime() + } + } + + suspend fun signOut() { + isSetup.await() + operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.signOut() + } + resetRuntime() + } + } + + suspend fun forceSignOut() { + operationMutex.withLock { + clearSessionAccessLocked() + clearStateLocked() + } + } + + suspend fun clearSessionAccess() { + operationMutex.withLock { + clearSessionAccessLocked() + notifyBackupStateChanged() + } + } + + private suspend fun clearSessionAccessLocked() { + sessionProvider.clearLiveSessionAccess() + keychain.delete(Keychain.Key.PAYKIT_SESSION.name) + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + activeAuthRequest = null + resetRuntime() + } + + suspend fun clearState() { + operationMutex.withLock { + clearStateLocked() + } + } + + private suspend fun clearStateLocked() { + keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) + activeAuthRequest = null + resetRuntime() + notifyBackupStateChanged() + } + + private suspend fun currentSdkStatePublicKeyLocked(): String? { + return runCatching { handle().identityStatus()?.publicKey } + .getOrElse { + keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) + resetRuntime() + null + } + } + + private suspend fun persistSessionAccess( + access: PubkySessionAccess, + shouldStoreLocalSecret: Boolean, + ) { + keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) + val localSecret = access.exportLocalSecretKey() + if (shouldStoreLocalSecret && localSecret != null) { + keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) + } else { + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + } + + private suspend fun activateBootstrapResult( + result: PubkySessionBootstrapResult, + previousPublicKey: String?, + shouldStoreLocalSecret: Boolean, + ) { + persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret) + sessionProvider.setLiveSessionAccess(result.sessionAccess) + if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { + keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) + } + resetRuntime() + handle().initialize() + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { + val handle = handle() + val previousRevision = runCatching { handle.stateRevision() }.getOrNull() + return try { + block(handle).also { + notifyBackupStateChangedIfNeeded(previousRevision, handle) + } + } catch (error: Throwable) { + notifyBackupStateChangedIfNeeded(previousRevision, handle) + throw error + } + } + + private fun notifyBackupStateChangedIfNeeded(previousRevision: String?, handle: PaykitSdk) { + val nextRevision = runCatching { handle.stateRevision() }.getOrNull() + if (previousRevision != nextRevision) { + notifyBackupStateChanged() + } + } + + private suspend fun handle(): PaykitSdk = handleMutex.withLock { + sdk?.let { return@withLock it } + PaykitSdk.withPaymentAdapter( + stateStore = stateStore, + sessionProvider = sessionProvider, + paymentAdapter = paymentAdapter, + config = paykitSdkConfig(), + ).also { sdk = it } + } + + private fun resetRuntime() { + sdk = null + } + + companion object { + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = + PubkyLocalSecretKey(secretKeyHex.fromHex()) + + fun secretKeyHex(secretKey: PubkyLocalSecretKey): String = + secretKey.exportBytes().toHex() + + private const val PUBKY_DERIVATION_RUNTIME_LABEL = "bitkit" + + fun deriveSecretKey(seed: ByteArray): String = + secretKeyHex(derivePubkySecretKey(seed = seed, runtimeLabel = PUBKY_DERIVATION_RUNTIME_LABEL)) + + fun publicKeyFromSecret(secretKeyHex: String): String = + pubkyPublicKeyFromSecret(localSecretKey(secretKeyHex)) + + fun parseAuthUrl(authUrl: String) = + parsePubkyAuthUrl(authUrl) + } +} + +internal object BitkitPaykitSdkConfig { + val profileNamespace: String + get() = if (Env.network == Network.BITCOIN) "bitkit.to" else "staging.bitkit.to" + val endpointManagementScope = PaykitSdkDefaults.DEFAULT_ENDPOINT_MANAGEMENT_SCOPE + val encryptedLinkRecoveryMarkers = PaykitSdkDefaults.DEFAULT_ENCRYPTED_LINK_RECOVERY_MARKER_POLICY + val publicContactSharing = PaykitSdkDefaults.DEFAULT_PUBLIC_CONTACT_SHARING_POLICY +} + +internal fun paykitSdkConfig() = defaultConfig().copy( + profileNamespace = BitkitPaykitSdkConfig.profileNamespace, + endpointManagementScope = BitkitPaykitSdkConfig.endpointManagementScope, + encryptedLinkRecoveryMarkers = BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers, + publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, +) + +private class PaykitSdkStateBlobStore( + private val keychain: Keychain, +) : SdkStateBlobStore { + private val lock = Any() + + override fun loadStateBlob(): SdkStateBlobSnapshot? = synchronized(lock) { + val data = keychain.load(Keychain.Key.PAYKIT_SDK_STATE.name) ?: return@synchronized null + decodeSdkStateBlobSnapshot(data) + } + + override fun saveStateBlobAtomically( + blob: SdkStateBlob, + expectedRevision: String?, + ): String = synchronized(lock) { + val currentRevision = keychain.load(Keychain.Key.PAYKIT_SDK_STATE.name) + ?.let { decodeSdkStateBlobSnapshot(it).revision } + if (currentRevision != expectedRevision) { + throw PaykitException.Storage( + code = "revision_conflict", + context = "SDK state revision changed", + ) + } + + val nextRevision = UUID.randomUUID().toString() + val snapshot = SdkStateBlobSnapshot(blob = blob, revision = nextRevision) + keychain.upsert(Keychain.Key.PAYKIT_SDK_STATE.name, encodeSdkStateBlobSnapshot(snapshot)) + nextRevision + } +} + +private class PaykitSdkSessionProvider( + private val keychain: Keychain, +) : SdkPubkySessionProvider { + private val lock = Any() + private var liveSessionAccess: PubkySessionAccess? = null + + fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { + liveSessionAccess = access + } + + fun clearLiveSessionAccess() = synchronized(lock) { + liveSessionAccess = null + } + + override fun loadSessionAccess(): PubkySessionAccess? { + val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + ?.takeIf { it.isNotBlank() } + ?: return null + + synchronized(lock) { + liveSessionAccess + ?.takeIf { it.exportSessionSecret() == sessionSecret } + ?.let { return it } + } + + return PubkySessionAccess( + sessionSecret = sessionSecret, + localSecretKey = loadLocalSecretKey(), + ) + } + + override fun publicStorageAvailable(): Boolean = true + + override fun clearSessionAccess() { + runBlocking { + clearLiveSessionAccess() + keychain.delete(Keychain.Key.PAYKIT_SESSION.name) + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + } + + fun loadLocalSecretKey(): PubkyLocalSecretKey? { + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + ?.takeIf { it.isNotBlank() } + ?: return null + return PaykitSdkService.localSecretKey(secretKeyHex) + } +} + +class PaykitSdkPaymentAdapter : SdkPaymentAdapter { + override fun currentReceivingDetails(scope: ReceivingDetailScope): List = emptyList() + + override fun reserveReceivingDetails(counterparty: String): ReceivingDetailReservationResponse = + ReceivingDetailReservationResponse( + kind = ReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, + reservations = emptyList(), + ) + + override fun cancelReceivingDetailReservation(cancellation: PaymentEndpointReservationCancellation) = Unit + + override fun selectPaymentEndpointIds(request: PaymentEndpointSelectionRequest): List { + val parsed = request.candidates.mapNotNull { candidate -> + PublicPaykitRepo.parseEndpoint( + methodId = candidate.identifier, + endpointData = candidate.payload.exportText(), + )?.let { candidate.candidateId to it } + } + return PublicPaykitRepo.payablePreferenceOrder.flatMap { methodId -> + parsed.mapNotNull { (id, endpoint) -> id.takeIf { endpoint.methodId == methodId } } + } + } + + override fun buildPaymentTarget(endpoint: PaymentEndpointCandidate): PaymentTarget = + PaymentTarget(endpoint.payload) +} + +private fun Endpoint.toReceivingDetail() = ReceivingDetail( + identifier = methodId.rawValue, + payload = PaymentPayload(rawPayload), +) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index d7dcbedf6b..aeff560047 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,57 +1,10 @@ package to.bitkit.services -import android.content.Context -import com.synonym.bitkitcore.PubkyProfile -import com.synonym.bitkitcore.approvePubkyAuth -import com.synonym.bitkitcore.cancelPubkyAuth -import com.synonym.bitkitcore.completePubkyAuth -import com.synonym.bitkitcore.derivePubkySecretKey -import com.synonym.bitkitcore.fetchPubkyContacts -import com.synonym.bitkitcore.fetchPubkyFile -import com.synonym.bitkitcore.fetchPubkyProfile import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.bitkitcore.parsePubkyAuthUrl -import com.synonym.bitkitcore.pubkyPublicKeyFromSecret -import com.synonym.bitkitcore.pubkyPutWithSecretKey -import com.synonym.bitkitcore.pubkySessionDelete -import com.synonym.bitkitcore.pubkySessionList -import com.synonym.bitkitcore.pubkySessionPut -import com.synonym.bitkitcore.pubkySignIn -import com.synonym.bitkitcore.pubkySignUp -import com.synonym.bitkitcore.startPubkyAuth -import com.synonym.paykit.FfiHandshakeProgress -import com.synonym.paykit.FfiPaymentEntry -import com.synonym.paykit.FfiPrivatePaymentsPayload -import com.synonym.paykit.PaykitAndroid -import com.synonym.paykit.paykitAcceptEncryptedLink -import com.synonym.paykit.paykitAdvanceHandshake -import com.synonym.paykit.paykitCloseEncryptedLink -import com.synonym.paykit.paykitDropEncryptedLinkHandshake -import com.synonym.paykit.paykitEncryptedLinkHandshakeSnapshotRecipient -import com.synonym.paykit.paykitEncryptedLinkSnapshotRecipient -import com.synonym.paykit.paykitExportSession -import com.synonym.paykit.paykitForceSignOut -import com.synonym.paykit.paykitGeneratePaymentReference -import com.synonym.paykit.paykitGetCurrentPublicKey -import com.synonym.paykit.paykitGetPaymentEndpoint -import com.synonym.paykit.paykitGetPaymentList -import com.synonym.paykit.paykitGetPrivatePayments -import com.synonym.paykit.paykitImportSession -import com.synonym.paykit.paykitInitialize -import com.synonym.paykit.paykitInitiateEncryptedLink -import com.synonym.paykit.paykitIsAuthenticated -import com.synonym.paykit.paykitRemovePaymentEndpoint -import com.synonym.paykit.paykitRestoreEncryptedLink -import com.synonym.paykit.paykitRestoreEncryptedLinkHandshake -import com.synonym.paykit.paykitSerializeEncryptedLink -import com.synonym.paykit.paykitSerializeEncryptedLinkHandshake -import com.synonym.paykit.paykitSetPaymentEndpoint -import com.synonym.paykit.paykitSetPrivatePayments -import com.synonym.paykit.paykitSignOut -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CompletableDeferred +import com.synonym.paykit.ContactProfileResolution +import com.synonym.paykit.ContactRecord +import com.synonym.paykit.PaykitProfile import to.bitkit.async.ServiceQueue -import to.bitkit.env.Env import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton @@ -59,152 +12,41 @@ import javax.inject.Singleton @Suppress("TooManyFunctions") @Singleton class PubkyService @Inject constructor( - @ApplicationContext private val context: Context, + private val paykitSdkService: PaykitSdkService, ) { - - private val isSetup = CompletableDeferred() - suspend fun initialize() = ServiceQueue.CORE.background { - if (!PaykitAndroid.initialize(context)) { - throw AppError("Failed to initialize Android platform verifier") - } - paykitInitialize() - isSetup.complete(Unit) + paykitSdkService.initialize() } // region Session management suspend fun importSession(secret: String): String = ServiceQueue.CORE.background { - isSetup.await() - paykitImportSession(secret) - } - - suspend fun exportSession(): String = ServiceQueue.CORE.background { - isSetup.await() - paykitExportSession() + paykitSdkService.importSession(secret).publicKey } - suspend fun isAuthenticated(): Boolean = ServiceQueue.CORE.background { - isSetup.await() - paykitIsAuthenticated() + suspend fun importExternalSession(secret: String): String = ServiceQueue.CORE.background { + paykitSdkService.importSession(secret, includeLocalSecret = false).publicKey } suspend fun currentPublicKey(): String? = ServiceQueue.CORE.background { - isSetup.await() - paykitGetCurrentPublicKey() + paykitSdkService.currentPublicKey() } suspend fun signOut() = ServiceQueue.CORE.background { - isSetup.await() - paykitSignOut() + paykitSdkService.signOut() } suspend fun forceSignOut() = ServiceQueue.CORE.background { - isSetup.await() - paykitForceSignOut() - } - - // endregion - - // region Payment endpoints - - suspend fun getPaymentList(publicKey: String): List = ServiceQueue.CORE.background { - isSetup.await() - paykitGetPaymentList(publicKey) - } - - suspend fun getPaymentEndpoint(publicKey: String, methodId: String): String? = ServiceQueue.CORE.background { - isSetup.await() - paykitGetPaymentEndpoint(publicKey, methodId) + paykitSdkService.forceSignOut() } - suspend fun setPaymentEndpoint(methodId: String, endpointData: String) = ServiceQueue.CORE.background { - isSetup.await() - paykitSetPaymentEndpoint(methodId, endpointData) + suspend fun clearSessionAccess() = ServiceQueue.CORE.background { + paykitSdkService.clearSessionAccess() } - suspend fun removePaymentEndpoint(methodId: String) = ServiceQueue.CORE.background { - isSetup.await() - paykitRemovePaymentEndpoint(methodId) - } - - // endregion - - // region Private payment endpoints - - suspend fun initiateEncryptedLink(secretKeyHex: String, receiverPublicKey: String): String = - ServiceQueue.CORE.background { - isSetup.await() - paykitInitiateEncryptedLink(secretKeyHex, receiverPublicKey) - } - - suspend fun acceptEncryptedLink(secretKeyHex: String, senderPublicKey: String): String = - ServiceQueue.CORE.background { - isSetup.await() - paykitAcceptEncryptedLink(secretKeyHex, senderPublicKey) - } - - suspend fun advanceHandshake(handshakeId: String): FfiHandshakeProgress = ServiceQueue.CORE.background { - isSetup.await() - paykitAdvanceHandshake(handshakeId) - } - - suspend fun restoreEncryptedLink(secretKeyHex: String, snapshotHex: String): String = - ServiceQueue.CORE.background { - isSetup.await() - paykitRestoreEncryptedLink(secretKeyHex, snapshotHex) - } - - suspend fun encryptedLinkSnapshotRecipient(snapshotHex: String): String = ServiceQueue.CORE.background { - isSetup.await() - paykitEncryptedLinkSnapshotRecipient(snapshotHex) - } - - suspend fun restoreEncryptedLinkHandshake(secretKeyHex: String, snapshotHex: String): String = - ServiceQueue.CORE.background { - isSetup.await() - paykitRestoreEncryptedLinkHandshake(secretKeyHex, snapshotHex) - } - - suspend fun encryptedLinkHandshakeSnapshotRecipient(snapshotHex: String): String = - ServiceQueue.CORE.background { - isSetup.await() - paykitEncryptedLinkHandshakeSnapshotRecipient(snapshotHex) - } - - suspend fun serializeEncryptedLink(linkId: String): String = ServiceQueue.CORE.background { - isSetup.await() - paykitSerializeEncryptedLink(linkId) - } - - suspend fun serializeEncryptedLinkHandshake(handshakeId: String): String = ServiceQueue.CORE.background { - isSetup.await() - paykitSerializeEncryptedLinkHandshake(handshakeId) - } - - suspend fun closeEncryptedLink(linkId: String) = ServiceQueue.CORE.background { - isSetup.await() - paykitCloseEncryptedLink(linkId) - } - - suspend fun dropEncryptedLinkHandshake(handshakeId: String) = ServiceQueue.CORE.background { - isSetup.await() - paykitDropEncryptedLinkHandshake(handshakeId) - } - - suspend fun setPrivatePayments(linkId: String, entries: List) = - ServiceQueue.CORE.background { - isSetup.await() - val payload = FfiPrivatePaymentsPayload( - reference = paykitGeneratePaymentReference(), - entries = entries, - ) - paykitSetPrivatePayments(linkId, payload) - } - - suspend fun getPrivatePayments(linkId: String): FfiPrivatePaymentsPayload? = ServiceQueue.CORE.background { - isSetup.await() - paykitGetPrivatePayments(linkId) + suspend fun removeBitkitPaymentEndpoints() = ServiceQueue.CORE.background { + val report = paykitSdkService.syncPublicEndpoints(emptyList()) + if (report.failed.isNotEmpty()) throw AppError("Failed to remove Paykit payment endpoints") } // endregion @@ -213,33 +55,30 @@ class PubkyService @Inject constructor( suspend fun mnemonicToSeed(mnemonic: String, passphrase: String?): ByteArray = ServiceQueue.CORE.background { - isSetup.await() mnemonicToSeed(mnemonicPhrase = mnemonic, passphrase = passphrase ?: "") } suspend fun deriveSecretKey(seed: ByteArray): String = ServiceQueue.CORE.background { - isSetup.await() - derivePubkySecretKey(seed) + PaykitSdkService.deriveSecretKey(seed) } suspend fun publicKeyFromSecret(secretKeyHex: String): String = ServiceQueue.CORE.background { - isSetup.await() - pubkyPublicKeyFromSecret(secretKeyHex) + PaykitSdkService.publicKeyFromSecret(secretKeyHex) } // endregion // region Homeserver auth - suspend fun signUp(secretKeyHex: String, homeserverZ32: String, signupCode: String?): String = + suspend fun signUp(secretKeyHex: String, homeserverZ32: String, signupCode: String?): Unit = ServiceQueue.CORE.background { - isSetup.await() - pubkySignUp(secretKeyHex, homeserverZ32, signupCode) + paykitSdkService.signUp(secretKeyHex, homeserverZ32, signupCode) + Unit } - suspend fun signIn(secretKeyHex: String): String = ServiceQueue.CORE.background { - isSetup.await() - pubkySignIn(secretKeyHex) + suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background { + paykitSdkService.signIn(secretKeyHex) + Unit } // endregion @@ -247,18 +86,16 @@ class PubkyService @Inject constructor( // region Auth flow (Ring) suspend fun startAuth(): String = ServiceQueue.CORE.background { - isSetup.await() - startPubkyAuth(Env.pubkyCapabilities) + paykitSdkService.startAuth() } - suspend fun completeAuth(): String = ServiceQueue.CORE.background { - isSetup.await() - completePubkyAuth() + suspend fun completeAuth(): Unit = ServiceQueue.CORE.background { + paykitSdkService.completeAuth() + Unit } suspend fun cancelAuth() = ServiceQueue.CORE.background { - isSetup.await() - cancelPubkyAuth() + paykitSdkService.cancelAuth() } // endregion @@ -266,13 +103,11 @@ class PubkyService @Inject constructor( // region Auth approval suspend fun parseAuthUrl(url: String) = ServiceQueue.CORE.background { - isSetup.await() - parsePubkyAuthUrl(url) + PaykitSdkService.parseAuthUrl(url) } suspend fun approveAuth(authUrl: String, secretKeyHex: String) = ServiceQueue.CORE.background { - isSetup.await() - approvePubkyAuth(authUrl, secretKeyHex) + paykitSdkService.approveAuth(authUrl, secretKeyHex) } // endregion @@ -280,51 +115,46 @@ class PubkyService @Inject constructor( // region File operations suspend fun fetchFile(uri: String): ByteArray = ServiceQueue.CORE.background { - isSetup.await() - fetchPubkyFile(uri) + paykitSdkService.fetchFile(uri) } - suspend fun fetchFileString(uri: String): String = ServiceQueue.CORE.background { - isSetup.await() - fetchPubkyFile(uri).toString(Charsets.UTF_8) - } + // endregion - suspend fun sessionPut(sessionSecret: String, path: String, content: ByteArray) = - ServiceQueue.CORE.background { - isSetup.await() - pubkySessionPut(sessionSecret, path, content) - } + // region Profile & contacts - suspend fun sessionDelete(sessionSecret: String, path: String) = - ServiceQueue.CORE.background { - isSetup.await() - pubkySessionDelete(sessionSecret, path) - } + suspend fun publishPaykitProfile(profile: PaykitProfile) = ServiceQueue.CORE.background { + paykitSdkService.publishPaykitProfile(profile) + } - suspend fun sessionList(sessionSecret: String, dirPath: String): List = - ServiceQueue.CORE.background { - isSetup.await() - pubkySessionList(sessionSecret, dirPath) - } + suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.CORE.background { + paykitSdkService.uploadProfileAvatar(bytes, contentType) + } - suspend fun putWithSecretKey(secretKeyHex: String, path: String, content: ByteArray) = - ServiceQueue.CORE.background { - isSetup.await() - pubkyPutWithSecretKey(secretKeyHex, path, content) - } + suspend fun deletePaykitProfile() = ServiceQueue.CORE.background { + paykitSdkService.deletePaykitProfile() + } - // endregion + suspend fun getContacts(publicKey: String): List = ServiceQueue.CORE.background { + paykitSdkService.fetchPubkyFollows(publicKey) + } - // region Profile & contacts + suspend fun contactRecords(): List = ServiceQueue.CORE.background { + paykitSdkService.contactRecords() + } - suspend fun getProfile(publicKey: String): PubkyProfile = ServiceQueue.CORE.background { - isSetup.await() - fetchPubkyProfile(publicKey) + suspend fun saveContact(publicKey: String, label: String?): ContactRecord = ServiceQueue.CORE.background { + paykitSdkService.saveContact(publicKey, label) } - suspend fun getContacts(publicKey: String): List = ServiceQueue.CORE.background { - isSetup.await() - fetchPubkyContacts(publicKey) + suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { + paykitSdkService.removeContact(publicKey) + } + + suspend fun resolveContactProfile( + publicKey: String, + allowPubkyProfileFallback: Boolean, + ): ContactProfileResolution? = ServiceQueue.CORE.background { + paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) } // endregion diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index e4eb21770c..1f1c967f8f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -227,22 +227,34 @@ class EditProfileViewModel @Inject constructor( fun disconnectProfile() { viewModelScope.launch { _uiState.update { it.copy(showDeleteFailureDialog = false, isSaving = true) } - privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) - pubkyRepo.signOut() - .onSuccess { - _uiState.update { it.copy(isSaving = false) } - _effects.emit(EditProfileEffect.DisconnectSuccess) - } - .onFailure { - Logger.error("Failed to disconnect profile", it, context = TAG) - _uiState.update { it.copy(isSaving = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__disconnect_error), - description = it.message, - ) - } + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull( + cleanupResult.exceptionOrNull(), + ) { "Private Paykit cleanup failed without an error" } + _uiState.update { it.copy(isSaving = false) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__disconnect_error), + description = error.message, + ) + return@launch + } + val result = pubkyRepo.signOut() + if (result.isSuccess) { + privatePaykitRepo.closeAndClear() + _uiState.update { it.copy(isSaving = false) } + _effects.emit(EditProfileEffect.DisconnectSuccess) + } else { + val error = requireNotNull(result.exceptionOrNull()) { "Disconnect failed without an error" } + Logger.error("Failed to disconnect profile", error, context = TAG) + _uiState.update { it.copy(isSaving = false) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__disconnect_error), + description = error.message, + ) + } } } @@ -254,26 +266,33 @@ class EditProfileViewModel @Inject constructor( isSaving = true, ) } - privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) - pubkyRepo.deleteProfileWithSessionRetry() - .onSuccess { - _uiState.update { it.copy(isSaving = false) } - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.profile__delete_success), + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull( + cleanupResult.exceptionOrNull(), + ) { "Private Paykit cleanup failed without an error" } + _uiState.update { it.copy(showDeleteFailureDialog = true, isSaving = false) } + return + } + val result = pubkyRepo.deleteProfileWithSessionRetry() + if (result.isSuccess) { + privatePaykitRepo.closeAndClear() + _uiState.update { it.copy(isSaving = false) } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.profile__delete_success), + ) + _effects.emit(EditProfileEffect.DeleteSuccess) + } else { + val error = requireNotNull(result.exceptionOrNull()) { "Profile delete failed without an error" } + Logger.error("Failed to delete profile", error, context = TAG) + _uiState.update { + it.copy( + isSaving = false, + showDeleteFailureDialog = true, ) - _effects.emit(EditProfileEffect.DeleteSuccess) - } - .onFailure { - Logger.error("Failed to delete profile", it, context = TAG) - _uiState.update { - it.copy( - isSaving = false, - showDeleteFailureDialog = true, - ) - } } + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 49aaad2796..c9156cda81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -77,20 +77,32 @@ class ProfileViewModel @Inject constructor( viewModelScope.launch { _isSigningOut.update { true } _showSignOutDialog.update { false } - privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.closeAndClear(markProfileRecoveryPending = true) - pubkyRepo.signOut() - .onSuccess { - _effects.emit(ProfileEffect.SignedOut) - } - .onFailure { - Logger.error("Sign out failed", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__sign_out_title), - description = it.message, - ) - } + val cleanupResult = privatePaykitRepo.removePublishedEndpointsForCleanup(TAG) + if (cleanupResult.isFailure) { + val error = requireNotNull( + cleanupResult.exceptionOrNull(), + ) { "Private Paykit cleanup failed without an error" } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__sign_out_title), + description = error.message, + ) + _isSigningOut.update { false } + return@launch + } + val result = pubkyRepo.signOut() + if (result.isSuccess) { + privatePaykitRepo.closeAndClear() + _effects.emit(ProfileEffect.SignedOut) + } else { + val error = requireNotNull(result.exceptionOrNull()) { "Sign out failed without an error" } + Logger.error("Sign out failed", error, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__sign_out_title), + description = error.message, + ) + } _isSigningOut.update { false } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 0589013724..6bb2907a15 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -234,8 +234,7 @@ fun DevSettingsScreen( title = { Text("Enable Paykit UI?") }, text = { Text( - "Paykit features are still experimental and may not work reliably until supporting homeserver " + - "changes are deployed." + "Paykit features are experimental and may not work reliably." ) }, confirmButton = { diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index d757da09f5..ef324d0db8 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -50,11 +50,11 @@ class WipeWalletUseCase @Inject constructor( backupRepo.setWiping(true) backupRepo.reset() - privatePaykitRepo.get().removePublishedEndpointsBestEffort(TAG) - privatePaykitRepo.get().closeAndClear() - privatePaykitAddressReservationRepo.clear() + privatePaykitRepo.get().removePublishedEndpointsForCleanup(TAG) pubkyRepo.removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + privatePaykitRepo.get().closeAndClear() + privatePaykitAddressReservationRepo.clear() pubkyRepo.wipeLocalState() keychain.wipe() firebaseMessaging.deleteToken() diff --git a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt index 60efde08cb..68fbfb623c 100644 --- a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt @@ -11,17 +11,17 @@ class PubkyPublicKeyFormatTest { @Test fun `bounded trims lowercases and caps input`() { val overlongInput = - " PUBKYYBNDRFG8EJKMCPQXOT1UWISZA345H769YBNDRFG8EJKMCPQXOT1Uextra " + " PUBKY3RSDUHCXPW74SNWYCT86M38C63J3PQ8X4YCQIKXG64ROIK8YW5XGextra " val bounded = PubkyPublicKeyFormat.bounded(overlongInput) assertEquals(PubkyPublicKeyFormat.maximumInputLength, bounded.length) - assertEquals("pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u", bounded) + assertEquals("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", bounded) } @Test fun `normalized accepts prefixed and unprefixed keys`() { - val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" val prefixedKey = "pubky$rawKey" assertEquals(prefixedKey, PubkyPublicKeyFormat.normalized(rawKey)) @@ -33,21 +33,21 @@ class PubkyPublicKeyFormatTest { assertNull(PubkyPublicKeyFormat.normalized("pubkyshort")) assertNull( PubkyPublicKeyFormat.normalized( - "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot10", + "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5x0", ), ) } @Test fun `redacted shortens normalized pubky keys`() { - val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - assertEquals("pubkyyb…pqxot1u", PubkyPublicKeyFormat.redacted(rawKey)) + assertEquals("pubky3r…k8yw5xg", PubkyPublicKeyFormat.redacted(rawKey)) } @Test fun `matches compares equivalent pubky representations`() { - val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" val prefixedKey = "pubky$rawKey" assertTrue(PubkyPublicKeyFormat.matches(rawKey, prefixedKey)) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index c5d9d3648a..47051410bd 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -34,9 +34,9 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.di.json import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus -import to.bitkit.models.PrivatePaykitContactLinkBackupV1 import to.bitkit.models.WalletBackupV1 import to.bitkit.services.LightningService +import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import javax.inject.Provider @@ -56,6 +56,7 @@ class BackupRepoTest : BaseUnitTest() { private val blocktankRepo = mock() private val activityRepo = mock() private val pubkyRepo = mock() + private val paykitSdkService = mock() private val privatePaykitRepo = mock() private val privatePaykitAddressReservationRepo = mock() private val preActivityMetadataRepo = mock() @@ -227,13 +228,25 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `full restore should fail when private Paykit contact links fail to restore`() = test { + fun `full restore should continue when Paykit SDK state fails to restore`() = test { stubWalletBackup() whenever { privatePaykitRepo.restoreBackup(anyOrNull()) } .thenReturn(Result.failure(BackupRepoTestError("restore failed"))) val result = sut.performFullRestoreFromLatestBackup() + assertTrue(result.isSuccess) + verify(settingsStore).update(any()) + } + + @Test + fun `full restore should fail when backed up Paykit SDK state fails to restore`() = test { + stubWalletBackup(paykitSdkBackupState = "sdk-state") + whenever { privatePaykitRepo.restoreBackup("sdk-state") } + .thenReturn(Result.failure(BackupRepoTestError("restore failed"))) + + val result = sut.performFullRestoreFromLatestBackup() + assertTrue(result.isFailure) verify(settingsStore, never()).update(any()) } @@ -251,13 +264,13 @@ class BackupRepoTest : BaseUnitTest() { } private fun stubWalletBackup( - privatePaykitContactLinks: Map? = null, + paykitSdkBackupState: String? = null, ) { val walletBackup = WalletBackupV1( createdAt = 123, transfers = emptyList(), privatePaykitHighestReservedReceiveIndexByAddressType = mapOf("nativeSegwit" to 5), - privatePaykitContactLinks = privatePaykitContactLinks, + paykitSdkBackupState = paykitSdkBackupState, ) whenever { vssBackupClient.getObject(BackupCategory.WALLET.name) } .thenReturn( @@ -296,6 +309,7 @@ class BackupRepoTest : BaseUnitTest() { whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) whenever(pubkyRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever(preActivityMetadataRepo.preActivityMetadataChanged).thenReturn(MutableStateFlow(0L)) @@ -313,6 +327,7 @@ class BackupRepoTest : BaseUnitTest() { blocktankRepo = blocktankRepo, activityRepo = activityRepo, pubkyRepo = pubkyRepo, + paykitSdkService = paykitSdkService, privatePaykitRepo = Provider { privatePaykitRepo }, privatePaykitAddressReservationRepo = Provider { privatePaykitAddressReservationRepo }, preActivityMetadataRepo = preActivityMetadataRepo, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index 60a492cd0a..478721d46d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -24,7 +24,7 @@ import kotlin.test.assertNull class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { companion object { - private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PRIVATE_ADDRESS = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" } @@ -211,7 +211,7 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { @Test fun `clearContactAssignments removes stale private address attribution history`() = test { - val savedContactKey = "pubkyeytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + val savedContactKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" val savedPrivateAddress = "bcrt1qsavedweva9vextackckt6pjy0mmuc54gnn8peu" reservationData.value = PrivatePaykitReservationData( contactAssignments = mapOf( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt index 6fc30b101e..616d9e3a8b 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertNull class PrivatePaykitContactResolverTest : BaseUnitTest() { companion object { - private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PAYMENT_HASH = "010203" private const val PRIVATE_ADDRESS = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index cb01195c09..fc31117531 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4,81 +4,59 @@ import android.app.Activity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.FfiPaymentEntry -import com.synonym.paykit.FfiPrivatePaymentsPayload -import com.synonym.paykit.PaykitFfiException +import com.synonym.paykit.IdentityStatus +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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.junit.After import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.clearInvocations 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.App import to.bitkit.CurrentActivity import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore -import to.bitkit.data.PrivatePaykitContactCacheData -import to.bitkit.data.PrivatePaykitStoredPaymentEntryData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.keychain.Keychain import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.PrivatePaykitContactLinkBackupV1 -import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest -import to.bitkit.utils.AppError -import java.security.MessageDigest import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant -@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { - private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - private const val OWN_KEY = "pubkyeytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - private const val SECRET_KEY_HEX = "secret" - private const val LINK_ID = "link-id" - private const val HANDSHAKE_ID = "handshake-id" - private const val LINK_SNAPSHOT = "link-snapshot" - private const val UPDATED_LINK_SNAPSHOT = "updated-link-snapshot" - private const val HANDSHAKE_SNAPSHOT = "handshake-snapshot" - private const val UPDATED_HANDSHAKE_SNAPSHOT = "updated-handshake-snapshot" - private const val LOCAL_PAYLOAD_HASH = "local-payload-hash" + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val OWN_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" private const val PRIVATE_BOLT11 = "lnbcrt1private" - private const val PRIVATE_PAYMENT_HASH = "010203" - private const val PAYLOAD_LIMIT_BOLT11_LENGTH = 775 + private const val PRIVATE_BOLT11_EXPIRY_SECONDS = 86_400u private const val NOW_SECONDS = 1_700_000_000L - private const val TOMBSTONE_PAYLOAD = """{"value":""}""" } + private val paykitSdkService = mock() private val pubkyService = mock() - private val keychain = mock() private val cacheStore = mock() private val settingsStore = mock() private val addressReservationRepo = mock() @@ -97,7 +75,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private lateinit var sut: PrivatePaykitRepo @Before - fun setUp() { + fun setUp() = test { cacheData.value = PrivatePaykitCacheData() settingsData.value = SettingsData() @@ -108,20 +86,34 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } whenever { cacheStore.reset() }.thenAnswer { cacheData.value = PrivatePaykitCacheData() - Unit } whenever(settingsStore.data).thenReturn(settingsData) whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) - PublicPaykitRepo.lightningRouteHintsValidator = { true } - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)).thenReturn(null) - whenever { keychain.delete(any()) }.thenReturn(Unit) - whenever { keychain.upsertString(any(), any()) }.thenReturn(Unit) - whenever { pubkyService.publicKeyFromSecret(SECRET_KEY_HEX) }.thenReturn(OWN_KEY) - whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) - whenever { addressReservationRepo.hasContactAssignment(any()) }.thenReturn(false) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + capability = PubkyIdentityCapability.PRIVATE_LINK_CAPABLE, + liveSessionAvailable = true, + privateLinkCapable = true, + ), + ) + whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) + whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY) } + .thenReturn(Result.success(PRIVATE_ADDRESS)) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } + .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) + whenever { paykitSdkService.clearPrivatePaymentList(any()) }.thenReturn(privateListDeliveryReport()) + whenever { publicPaykitRepo.beginPayment(any()) } + .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"))) + whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { it.getArgument>(0) } + whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) + PublicPaykitRepo.lightningRouteHintsValidator = { true } + App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } sut = createSut() } @@ -132,1241 +124,267 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `shouldInitiate returns true for lexicographically larger key`() { - val smallerKey = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - val largerKey = "pubkyzbndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - - assertTrue(PrivatePaykitRepo.shouldInitiate(largerKey, smallerKey)) - assertFalse(PrivatePaykitRepo.shouldInitiate(smallerKey, largerKey)) - } - - @Test - fun `shouldInitiate normalizes prefixed and unprefixed keys`() { - val smallerKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - val largerKey = "pubkyzbndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - - assertTrue(PrivatePaykitRepo.shouldInitiate(largerKey, smallerKey)) - } - - @Test - fun `restoreBackup preserves private link recovery and cached remote endpoint state`() = test { - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.encryptedLinkHandshakeSnapshotRecipient(HANDSHAKE_SNAPSHOT)).thenReturn(CONTACT_KEY) - val remoteEndpoints = mapOf(MethodId.P2wpkh.rawValue to PublicPaykitRepo.serializePayload("bcrt1qprivate")) - - sut.restoreBackup( - mapOf( - CONTACT_KEY to PrivatePaykitContactLinkBackupV1( - publicKey = CONTACT_KEY, - linkSnapshotHex = LINK_SNAPSHOT, - handshakeSnapshotHex = HANDSHAKE_SNAPSHOT, - remoteEndpoints = remoteEndpoints, - linkCompletedAt = NOW_SECONDS - 60, - handshakeUpdatedAt = NOW_SECONDS - 120, - recoveryStartedAt = NOW_SECONDS - 180, - mainRecoveryAttemptId = "main-attempt", - responderRecoveryAttemptId = "responder-attempt", - awaitingRecoveredRemoteEndpoints = true, - ), - ), - ).getOrThrow() - - val restored = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - - assertNotNull(restored) - assertEquals(LINK_SNAPSHOT, restored.linkSnapshotHex) - assertEquals(HANDSHAKE_SNAPSHOT, restored.handshakeSnapshotHex) - assertEquals(remoteEndpoints, restored.remoteEndpoints) - assertEquals(NOW_SECONDS - 60, restored.linkCompletedAt) - assertEquals(NOW_SECONDS - 120, restored.handshakeUpdatedAt) - assertEquals(NOW_SECONDS - 180, restored.recoveryStartedAt) - assertEquals("main-attempt", restored.mainRecoveryAttemptId) - assertEquals("responder-attempt", restored.responderRecoveryAttemptId) - assertTrue(restored.awaitingRecoveredRemoteEndpoints) - } - - @Test - fun `restoreBackup clears stale private cleanup markers`() = test { - cacheData.value = PrivatePaykitCacheData( - cleanupPending = true, - deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY), - profileRecoveryPending = true, - ) - - sut.restoreBackup(null).getOrThrow() - - assertFalse(cacheData.value.cleanupPending) - assertEquals(emptySet(), cacheData.value.deletedContactCleanupPendingPublicKeys) - assertFalse(cacheData.value.profileRecoveryPending) - } - - @Test - fun `closeAndClear can mark profile recovery pending when private contact state exists`() = test { - restoreContactBackup() - - sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() - - assertTrue(cacheData.value.profileRecoveryPending) - } - - @Test - fun `closeAndClear preserves existing profile recovery marker after state was cleared`() = test { - cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) - - sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() - - assertTrue(cacheData.value.profileRecoveryPending) - } - - @Test - fun `closeAndClear does not mark profile recovery for contacts remembered without private state`() = test { - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - sut.closeAndClear(markProfileRecoveryPending = true).getOrThrow() - - assertFalse(cacheData.value.profileRecoveryPending) - } - - @Test - fun `prepareSavedContacts starts profile recovery for saved contacts`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - profileRecoveryPending = true, - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - remoteEndpoints = listOf( - PrivatePaykitStoredPaymentEntryData( - methodId = MethodId.P2wpkh.rawValue, - endpointData = PublicPaykitRepo.serializePayload("bcrt1qstale"), - ), - ), - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - handshakeUpdatedAt = NOW_SECONDS - 120, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson(linkSnapshotHex = LINK_SNAPSHOT, handshakeSnapshotHex = HANDSHAKE_SNAPSHOT)) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - stubPendingFreshHandshake() - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - assertFalse(cacheData.value.profileRecoveryPending) - verify(pubkyService, times(2)).sessionDelete("session", "/pub/paykit/v0/private") - verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertNotNull(snapshot) - assertEquals(NOW_SECONDS, snapshot.recoveryStartedAt) - assertNull(snapshot.linkSnapshotHex) - assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) - assertEquals(emptyMap(), snapshot.remoteEndpoints) - assertNull(snapshot.linkCompletedAt) - assertEquals(NOW_SECONDS, snapshot.handshakeUpdatedAt) - } - - @Test - fun `prepareSavedContacts accepts newer recovery marker after recent link completion`() = test { - val remoteAttemptId = "remote-attempt" - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData(linkCompletedAt = NOW_SECONDS - 1), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - val remoteMarkerPath = recoveryMarkerPath(CONTACT_KEY, OWN_KEY) - val remoteMarkerJson = recoveryMarkerJson( - writerPublicKey = CONTACT_KEY, - readerPublicKey = OWN_KEY, - stage = "init", - attemptId = remoteAttemptId, - createdAt = NOW_SECONDS, + fun `prepareSavedContacts publishes private reservations through SDK`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) - whenever(pubkyService.fetchFileString(any())).thenAnswer { - val uri = it.getArgument(0) - if (uri.contains(remoteMarkerPath)) remoteMarkerJson else throw PrivatePaykitTestError("not found") - } - whenever(pubkyService.acceptEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY)).thenReturn(HANDSHAKE_ID) - whenever(pubkyService.advanceHandshake(HANDSHAKE_ID)) - .thenAnswer { throw PrivatePaykitTestError("transition_transport failed isHandshake") } - whenever(pubkyService.serializeEncryptedLinkHandshake(HANDSHAKE_ID)).thenReturn(UPDATED_HANDSHAKE_SNAPSHOT) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService).closeEncryptedLink(LINK_ID) - verify(pubkyService).acceptEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) - verify(pubkyService, never()).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertNotNull(snapshot) - assertEquals(remoteAttemptId, snapshot.responderRecoveryAttemptId) - assertNull(snapshot.linkSnapshotHex) - assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) - } - - @Test - fun `prepareSavedContacts keeps profile recovery pending when transport purge fails`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) - .thenAnswer { throw PrivatePaykitTestError("delete failed") } - whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) - .thenAnswer { throw PrivatePaykitTestError("list failed") } - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - assertTrue(cacheData.value.profileRecoveryPending) - verify(pubkyService, never()).initiateEncryptedLink(any(), any()) - } - - @Test - fun `refreshSavedContactEndpoints retries profile recovery purge before publishing`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) - .thenAnswer { throw PrivatePaykitTestError("delete failed") } - whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) - .thenAnswer { throw PrivatePaykitTestError("list failed") } - - sut.refreshSavedContactEndpoints(listOf(CONTACT_KEY)).getOrThrow() - - assertTrue(cacheData.value.profileRecoveryPending) - verify(pubkyService, never()).initiateEncryptedLink(any(), any()) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `refreshKnownSavedContactEndpoints retries profile recovery purge before publishing`() = test { - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) - .thenAnswer { throw PrivatePaykitTestError("delete failed") } - whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) - .thenAnswer { throw PrivatePaykitTestError("list failed") } - - sut.refreshKnownSavedContactEndpoints("test refresh").getOrThrow() - - assertTrue(cacheData.value.profileRecoveryPending) - verify(pubkyService, never()).initiateEncryptedLink(any(), any()) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `prepareSavedContacts fails immediate profile recovery when transport purge fails`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData(profileRecoveryPending = true) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("session") - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.sessionDelete("session", "/pub/paykit/v0/private")) - .thenAnswer { throw PrivatePaykitTestError("delete failed") } - whenever(pubkyService.sessionList("session", "/pub/paykit/v0/private/")) - .thenAnswer { throw PrivatePaykitTestError("list failed") } val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - assertTrue(result.isFailure) - assertTrue(cacheData.value.profileRecoveryPending) - verify(pubkyService, never()).initiateEncryptedLink(any(), any()) - } + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - @Test - fun `removeSavedContact tombstones private endpoints before clearing local state`() = test { - restoreContactBackup() - rememberSavedContact() - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - - sut.removeSavedContact(CONTACT_KEY).getOrThrow() - - verify(pubkyService).setPrivatePayments( - eq(LINK_ID), - argThat> { - isNotEmpty() && all { it.endpointData == TOMBSTONE_PAYLOAD } - }, - ) - verify(addressReservationRepo).clearContactAssignment(CONTACT_KEY) - assertNull(sut.backupSnapshot().getOrThrow()) + val update = captor.firstValue.single() + val reservation = update.reservations.single() + assertEquals(CONTACT_KEY, update.counterparty) + assertEquals(MethodId.P2wpkh.rawValue, reservation.identifier) + assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS), reservation.payload) + assertTrue(reservation.reservationId.startsWith("$CONTACT_KEY:${MethodId.P2wpkh.rawValue}:")) + assertTrue(reservation.reservationId.length <= 128) + assertEquals("private_paykit", reservation.attribution["type"]) + assertEquals(CONTACT_KEY, reservation.attribution["counterparty"]) + assertEquals(true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) } @Test - fun `removeSavedContact preserves state and marks cleanup pending when tombstone fails`() = test { - restoreContactBackup() - rememberSavedContact() - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.setPrivatePayments(eq(LINK_ID), any())) - .thenAnswer { throw PrivatePaykitTestError("network failed") } - - val result = sut.removeSavedContact(CONTACT_KEY) - - assertTrue(result.isFailure) - assertFalse(cacheData.value.cleanupPending) - assertEquals(setOf(CONTACT_KEY), cacheData.value.deletedContactCleanupPendingPublicKeys) - assertNotNull(sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY)) - verify(addressReservationRepo, never()).clearContactAssignment(CONTACT_KEY) - } - - @Test - fun `disableSharingAndPruneUnsavedContactState fails and defers cleanup when endpoint removal fails`() = test { - restoreContactBackup() - rememberSavedContact() - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.setPrivatePayments(eq(LINK_ID), any())) - .thenAnswer { throw PrivatePaykitTestError("network failed") } - - val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - - assertTrue(result.isFailure) - assertTrue(cacheData.value.cleanupPending) - assertNotNull(sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY)) - } - - @Test - fun `retryPendingEndpointRemoval tombstones deleted contact without unpublishing public endpoints`() = test { - restoreContactBackup() - cacheData.value = cacheData.value.copy( - deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY), - ) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - - sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() - - verify(publicPaykitRepo, never()).syncPublishedEndpoints(false) - verify(pubkyService).setPrivatePayments( - eq(LINK_ID), - argThat> { - isNotEmpty() && all { it.endpointData == TOMBSTONE_PAYLOAD } - }, - ) - verify(addressReservationRepo).clearContactAssignment(CONTACT_KEY) - assertEquals(emptySet(), cacheData.value.deletedContactCleanupPendingPublicKeys) - assertNull(sut.backupSnapshot().getOrThrow()) - } - - @Test - fun `retryPendingEndpointRemoval clears stale sharing cleanup marker when sharing is enabled`() = test { - cacheData.value = cacheData.value.copy(cleanupPending = true) + fun `prepareSavedContacts succeeds when link preparation fails but SDK queues reservations`() = test { settingsData.value = SettingsData( - sharesPublicPaykitEndpoints = true, sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) + whenever { paykitSdkService.ensureLinkWithPeer(CONTACT_KEY) }.thenThrow(RuntimeException("still linking")) - sut.retryPendingEndpointRemoval(emptyList()).getOrThrow() - - assertFalse(cacheData.value.cleanupPending) - verify(publicPaykitRepo, never()).syncPublishedEndpoints(false) - } - - @Test - fun `failed private endpoint publish retries even with previous payload hash`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn( - Result.success("bcrt1qprivate"), - ) - whenever(pubkyService.setPrivatePayments(eq(LINK_ID), any())) - .thenAnswer { throw PrivatePaykitTestError("network failed") } - .thenAnswer { Unit } - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - advanceTimeBy(5_000) - runCurrent() - - verify(pubkyService, times(2)).setPrivatePayments(eq(LINK_ID), any()) - } - - @Test - fun `prepareSavedContacts does not publish private address when reusable receive refresh fails`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn( - Result.success("bcrt1qprivate"), - ) - whenever { walletRepo.refreshReusableReceiveAddressIfReserved() } - .thenReturn(Result.failure(PrivatePaykitTestError("refresh failed"))) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService).getPrivatePayments(LINK_ID) - verify(pubkyService, never()).setPrivatePayments(eq(LINK_ID), any()) - } - - @Test - fun `prepareSavedContacts does not retry when private key is unavailable`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - advanceTimeBy(5_000) - runCurrent() - - verify(keychain, times(1)).loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - verify(pubkyService, never()).restoreEncryptedLink(any(), any()) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `prepareSavedContacts fails immediate publication when private key is unavailable`() = test { - startForegroundWithSharingEnabled() - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - - val result = sut.prepareSavedContacts( - publicKeys = listOf(CONTACT_KEY), - requireImmediatePublication = true, - ) - - assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) - verify(pubkyService, never()).restoreEncryptedLink(any(), any()) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `enableSharingAndPrepareSavedContacts restores pending cleanup marker when prepare fails`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData(cleanupPending = true) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() } - .thenReturn(Result.failure(PrivatePaykitTestError("reconcile failed"))) - - val result = sut.enableSharingAndPrepareSavedContacts(listOf(CONTACT_KEY)) - - assertTrue(result.isFailure) - assertTrue(cacheData.value.cleanupPending) - } - - @Test - fun `prepareSavedContacts clears mismatched link snapshot and starts fresh handshake`() = test { - startForegroundWithSharingEnabled() - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(OWN_KEY) - stubPendingFreshHandshake() - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService, never()).restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT) - verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertNotNull(snapshot) - assertNull(snapshot.linkSnapshotHex) - assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) - } - - @Test - fun `prepareSavedContacts clears mismatched handshake snapshot and starts fresh handshake`() = test { - startForegroundWithSharingEnabled() - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson(linkSnapshotHex = null, handshakeSnapshotHex = HANDSHAKE_SNAPSHOT)) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - whenever(pubkyService.encryptedLinkHandshakeSnapshotRecipient(HANDSHAKE_SNAPSHOT)).thenReturn(OWN_KEY) - stubPendingFreshHandshake() - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService, never()).restoreEncryptedLinkHandshake(SECRET_KEY_HEX, HANDSHAKE_SNAPSHOT) - verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertNotNull(snapshot) - assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) - } - - @Test - fun `prepareSavedContacts publishes after fetching empty remote endpoints for fresh initiator link`() = test { - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn( - Result.success("bcrt1qprivate"), - ) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService).getPrivatePayments(LINK_ID) - verify(pubkyService).setPrivatePayments( - eq(LINK_ID), - argThat> { - any { it.endpointData == PublicPaykitRepo.serializePayload("bcrt1qprivate") } - }, - ) - } - - @Test - fun `prepareSavedContacts returns NoSupportedEndpoint when immediate publish has no endpoint`() = test { - startForegroundWithSharingEnabled() - settingsData.value = settingsData.value.copy(publicPaykitOnchainEnabled = false) - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(lightningRepo.canReceive()).thenReturn(false) - - val error = sut.prepareSavedContacts( - publicKeys = listOf(CONTACT_KEY), - requireImmediatePublication = true, - ).exceptionOrNull() - - assertEquals(PublicPaykitError.NoSupportedEndpoint, error) - verify(pubkyService, never()).setPrivatePayments(eq(LINK_ID), any()) - } - - @Test - fun `prepareSavedContacts fails fresh link when immediate publication is requested`() = test { - startForegroundWithSharingEnabled() - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - stubPendingFreshHandshake() - - val result = sut.prepareSavedContacts( - publicKeys = listOf(CONTACT_KEY), - requireImmediatePublication = true, - ) - - assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull()) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `prepareSavedContacts fails immediate publish when stale fetch defers existing endpoint update`() = test { - val retryLinkId = "retry-link-id" - startForegroundWithSharingEnabled() - settingsData.value = settingsData.value.copy(publicPaykitOnchainEnabled = false) - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)) - .thenReturn(LINK_ID) - .thenReturn(retryLinkId) - whenever(pubkyService.getPrivatePayments(LINK_ID)) - .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } - whenever(pubkyService.getPrivatePayments(retryLinkId)) - .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } - whenever(lightningRepo.canReceive()).thenReturn(false) - - val error = sut.prepareSavedContacts( - publicKeys = listOf(CONTACT_KEY), - requireImmediatePublication = true, - ).exceptionOrNull() - - assertEquals(PrivatePaykitError.PrivateUnavailable, error) - verify(pubkyService, never()).setPrivatePayments(any(), any()) - } - - @Test - fun `prepareSavedContacts skips publish when eligibility changes after endpoint build`() = test { - startForegroundWithSharingEnabled() - whenever(walletRepo.walletExists()).thenReturn(true, true, false) - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn( - Result.success("bcrt1qprivate"), - ) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService).getPrivatePayments(LINK_ID) - verify(pubkyService, never()).setPrivatePayments(eq(LINK_ID), any()) - } - - @Test - fun `prepareSavedContacts measures private endpoint envelope with compact payload json`() = test { - val bolt11 = "l".repeat(PAYLOAD_LIMIT_BOLT11_LENGTH) - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn( - Result.success("bcrt1qprivate"), - ) - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(bolt11)) - whenever(coreService.decode(bolt11)).thenReturn( - Scanner.Lightning(lightningInvoice(bolt11, byteArrayOf(1, 2, 3))), - ) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - verify(pubkyService).setPrivatePayments( - eq(LINK_ID), - argThat> { - any { - it.methodId == MethodId.Bolt11.rawValue && - it.endpointData == PublicPaykitRepo.serializePayload(bolt11) - } - }, - ) + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(any(), eq(false)) } + assertEquals(true, cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) } @Test - fun `prepareSavedContacts drops lightning when raw endpoint map fits but envelope is too large`() = test { - val bolt11 = "l".repeat(850) - val address = "bcrt1qprivate" - val entriesOnlyPayload = mapOf( - MethodId.Bolt11.rawValue to PublicPaykitRepo.serializePayload(bolt11), - MethodId.P2wpkh.rawValue to PublicPaykitRepo.serializePayload(address), - ) - assertTrue(Json.encodeToString(entriesOnlyPayload).encodeToByteArray().size <= 1_000) - startForegroundWithSharingEnabled() - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - linkCompletedAt = NOW_SECONDS, - ), - ), + fun `prepareSavedContacts includes lightning payment hash in reservation attribution`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = true, + publicPaykitOnchainEnabled = false, ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY)).thenReturn(Result.success(address)) whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(bolt11)) - whenever(coreService.decode(bolt11)).thenReturn( - Scanner.Lightning(lightningInvoice(bolt11, byteArrayOf(1, 2, 3))), - ) - - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - - verify(pubkyService).setPrivatePayments( - eq(LINK_ID), - argThat> { - none { it.methodId == MethodId.Bolt11.rawValue } && - any { - it.methodId == MethodId.P2wpkh.rawValue && - it.endpointData == PublicPaykitRepo.serializePayload(address) - } - }, - ) - } - - @Test - fun `restoreBackup reports private state persistence failures`() = test { - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(cacheStore.update(any())).thenAnswer { throw PrivatePaykitTestError("disk failed") } - - val result = sut.restoreBackup( - mapOf( - CONTACT_KEY to PrivatePaykitContactLinkBackupV1( - publicKey = CONTACT_KEY, - linkSnapshotHex = LINK_SNAPSHOT, - linkCompletedAt = NOW_SECONDS, - ), - ), - ) - - assertTrue(result.exceptionOrNull() is PrivatePaykitError.StatePersistenceFailed) - } - - @Test - fun `stale private link failures clear cached endpoints and start recovery`() = test { - prepareStaleLinkFailure(PrivatePaykitTestError("decrypt failed")) - - repeat(3) { - assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow()) - } - - assertStaleLinkRecoveryStarted() - } - - @Test - fun `wrapped stale Paykit failures clear cached endpoints and start recovery`() = test { - prepareStaleLinkFailure( - PrivatePaykitTestError( - message = "service queue failed", - cause = PaykitFfiException.InvalidData("bad mac while decrypting payload"), - ), - ) - - repeat(3) { - assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow()) - } - - assertStaleLinkRecoveryStarted() - } + whenever { + lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS, + ) + }.thenReturn(Result.success(PRIVATE_BOLT11)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - @Test - fun `beginSavedContactPayment discards attempted private lightning invoices`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - remoteEndpoints = listOf( - PrivatePaykitStoredPaymentEntryData( - methodId = MethodId.Bolt11.rawValue, - endpointData = PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), - ), - ), - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) - whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( - Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 2, 3))), - ) - whenever(lightningRepo.getPayments()).thenReturn( - Result.success( - listOf( - paymentDetails( - id = PRIVATE_PAYMENT_HASH, - status = PaymentStatus.SUCCEEDED, - ), - ), - ), - ) - rememberSavedContact() + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) - assertNotNull(snapshot) - assertEquals(emptyMap(), snapshot.remoteEndpoints) + val reservation = captor.firstValue.single().reservations.single() + assertEquals(MethodId.Bolt11.rawValue, reservation.identifier) + assertEquals(PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), reservation.payload) + assertEquals("090909", reservation.attribution["payment_hash"]) } @Test - fun `beginSavedContactPayment defers public fallback while private recovery is pending`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - recoveryStartedAt = NOW_SECONDS, - ), - ), + fun `disable sharing removes onchain-only private publications from cache`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) - verify(publicPaykitRepo, never()).beginPayment(any()) + assertTrue(result.isSuccess) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY) } + assertTrue(cacheData.value.contacts.isEmpty()) } @Test - fun `beginSavedContactPayment keeps deferring public fallback while recovered link has no endpoints`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS, - lastCompletedRecoveryAttemptId = "attempt", - awaitingRecoveredRemoteEndpoints = true, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) - assertTrue(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) - verify(publicPaykitRepo, never()).beginPayment(any()) - - val secondResult = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + fun `closeAndClear clears SDK state`() = test { + val result = sut.closeAndClear() - assertEquals(PublicPaykitPaymentResult.NoEndpoint, secondResult) - assertTrue(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) - verify(publicPaykitRepo, never()).beginPayment(any()) + assertTrue(result.isSuccess) + verifyBlocking(paykitSdkService) { clearState() } + assertTrue(cacheData.value.contacts.isEmpty()) } @Test - fun `beginSavedContactPayment keeps deferring public fallback when recovered link returns tombstones`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS, - lastCompletedRecoveryAttemptId = "attempt", - awaitingRecoveredRemoteEndpoints = true, - ), + fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + capability = PubkyIdentityCapability.PUBLIC_ONLY, + liveSessionAvailable = true, + privateLinkCapable = false, ), ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)) - .thenReturn( - privatePaymentsPayload( - listOf( - FfiPaymentEntry(MethodId.Bolt11.rawValue, TOMBSTONE_PAYLOAD), - FfiPaymentEntry(MethodId.P2wpkh.rawValue, TOMBSTONE_PAYLOAD), - ), - ), - ) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) - assertTrue(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) - verify(publicPaykitRepo, never()).beginPayment(any()) - } - - @Test - fun `beginSavedContactPayment retries completed recovery until private endpoints arrive`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS, - lastCompletedRecoveryAttemptId = "attempt", - awaitingRecoveredRemoteEndpoints = true, + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)) - .thenReturn(null) - .thenReturn( - privatePaymentsPayload( - listOf( - FfiPaymentEntry( - MethodId.Bolt11.rawValue, - PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), - ), - ), - ), - ) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( - Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 2, 3))), - ) - whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) - assertFalse(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) - verify(publicPaykitRepo, never()).beginPayment(any()) + assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) + verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment marks backup changed when consumed recovery marker clears`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - remoteEndpoints = listOf( - PrivatePaykitStoredPaymentEntryData( - MethodId.Bolt11.rawValue, - PublicPaykitRepo.serializePayload(PRIVATE_BOLT11), - ), - ), - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS, - lastCompletedRecoveryAttemptId = "attempt", - awaitingRecoveredRemoteEndpoints = true, + fun `beginSavedContactPayment opens SDK resolved private endpoint`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.Bolt11, + value = PRIVATE_BOLT11, ), ), ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(coreService.decode(PRIVATE_BOLT11)).thenReturn( - Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(1, 2, 3))), - ) - whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - rememberSavedContact() + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - val previousBackupVersion = sut.backupStateVersion.value val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) - assertFalse(cacheData.value.contacts[CONTACT_KEY]?.awaitingRecoveredRemoteEndpoints == true) - assertTrue(sut.backupStateVersion.value > previousBackupVersion) - verify(publicPaykitRepo, never()).beginPayment(any()) - } - - @Test - fun `beginSavedContactPayment allows public fallback after recovered endpoints are consumed`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS, - lastCompletedRecoveryAttemptId = "attempt", - awaitingRecoveredRemoteEndpoints = false, + verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(null) - whenever(pubkyService.serializeEncryptedLink(LINK_ID)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), result) - verify(publicPaykitRepo).beginPayment(CONTACT_KEY) + assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } + assertEquals(CONTACT_KEY, captor.firstValue.single().counterparty) + verifyBlocking(paykitSdkService) { prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) } } @Test - fun `beginSavedContactPayment falls back promptly for non recovery private unavailable`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)) - .thenAnswer { throw PrivatePaykitError.PrivateUnavailable } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() + fun `beginSavedContactPayment does not fall back to public when unified resolution is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenThrow(CancellationException("cancelled")) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val result = sut.beginSavedContactPayment(CONTACT_KEY) - assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), result) - verify(publicPaykitRepo).beginPayment(CONTACT_KEY) - } - - @Test - fun `beginSavedContactPayment falls back to public for pending non recovery handshake`() = test { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is CancellationException) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment uses public endpoint from unified resolution when private has no endpoints`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson(linkSnapshotHex = null, handshakeSnapshotHex = HANDSHAKE_SNAPSHOT)) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkHandshakeSnapshotRecipient(HANDSHAKE_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever( - pubkyService.restoreEncryptedLinkHandshake( - SECRET_KEY_HEX, - HANDSHAKE_SNAPSHOT, - ), - ).thenReturn(HANDSHAKE_ID) - whenever(pubkyService.advanceHandshake(HANDSHAKE_ID)) - .thenAnswer { throw PrivatePaykitTestError("transition_transport failed isHandshake") } - whenever(pubkyService.serializeEncryptedLinkHandshake(HANDSHAKE_ID)).thenReturn(UPDATED_HANDSHAKE_SNAPSHOT) - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.Opened("bitcoin:public"))) - rememberSavedContact() val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:public"), result) - verify(publicPaykitRepo).beginPayment(CONTACT_KEY) + assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `discardRemoteOnchainEndpoints removes attempted private address from cache`() = test { - restoreContactBackup() - - sut.discardRemoteOnchainEndpoints(CONTACT_KEY, setOf("bcrt1qprivate")).getOrThrow() - - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertNotNull(snapshot) - assertEquals(emptyMap(), snapshot.remoteEndpoints) - } - - @Test - fun `stale private endpoint fetch restores link snapshot and retries once`() = test { - val retryLinkId = "retry-link-id" - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, + fun `beginSavedContactPayment falls back to public when private endpoints are not locally payable`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.Bolt11, + value = PRIVATE_BOLT11, ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)) - .thenReturn(LINK_ID) - .thenReturn(retryLinkId) - whenever(pubkyService.getPrivatePayments(LINK_ID)) - .thenAnswer { throw PaykitFfiException.InvalidData("bad mac while decrypting payload") } - whenever(pubkyService.getPrivatePayments(retryLinkId)).thenReturn( - privatePaymentsPayload( - listOf( - FfiPaymentEntry( - methodId = MethodId.P2wpkh.rawValue, - endpointData = PublicPaykitRepo.serializePayload("bcrt1qprivate"), - ), + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, ), ), ) - whenever(pubkyService.serializeEncryptedLink(retryLinkId)).thenReturn(UPDATED_LINK_SNAPSHOT) - whenever(publicPaykitRepo.payableEndpoints(any())).thenAnswer { it.getArgument>(0) } - whenever(coreService.isAddressUsed("bcrt1qprivate")).thenReturn(false) - whenever(lightningRepo.getPayments()).thenReturn(Result.success(emptyList())) - rememberSavedContact() + whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { + val endpoints = it.getArgument>(0) + val hasLightningEndpoint = endpoints.any { endpoint -> endpoint.methodId == MethodId.Bolt11 } + endpoints.takeUnless { hasLightningEndpoint }.orEmpty() + } val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - assertTrue(result is PublicPaykitPaymentResult.Opened) - assertNotNull(snapshot) - assertEquals( - mapOf(MethodId.P2wpkh.rawValue to PublicPaykitRepo.serializePayload("bcrt1qprivate")), - snapshot.remoteEndpoints, - ) - verify(pubkyService).closeEncryptedLink(LINK_ID) - verify(pubkyService).getPrivatePayments(retryLinkId) + assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `isDuplicatePaymentError detects wrapped duplicate payment messages`() { - val error = PrivatePaykitTestError("service queue failed", cause = AppError("Duplicate payment.")) + fun `backupSnapshot and restoreBackup use SDK backup state`() = test { + val backup = "sdk-backup" + whenever(paykitSdkService.exportBackupState()).thenReturn(backup) - assertTrue(PrivatePaykitRepo.isDuplicatePaymentError(error)) - } + val snapshot = sut.backupSnapshot().getOrThrow() + sut.restoreBackup(snapshot).getOrThrow() - private suspend fun prepareStaleLinkFailure(error: Throwable) { - cacheData.value = PrivatePaykitCacheData( - contacts = mapOf( - CONTACT_KEY to PrivatePaykitContactCacheData( - remoteEndpoints = listOf( - PrivatePaykitStoredPaymentEntryData( - methodId = MethodId.P2wpkh.rawValue, - endpointData = PublicPaykitRepo.serializePayload("bcrt1qprivate"), - ), - ), - lastLocalPayloadHash = LOCAL_PAYLOAD_HASH, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ) - whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) - .thenReturn(secretStateJson()) - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) - whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) - whenever(pubkyService.getPrivatePayments(LINK_ID)).thenAnswer { throw error } - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)) - .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) - rememberSavedContact() - } - - private suspend fun assertStaleLinkRecoveryStarted() { - val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) - - assertNotNull(snapshot) - assertNull(snapshot.linkSnapshotHex) - assertEquals(emptyMap(), snapshot.remoteEndpoints) - assertEquals(NOW_SECONDS, snapshot.recoveryStartedAt) + assertEquals(backup, snapshot) + verifyBlocking(paykitSdkService) { restoreBackupState(backup) } } private fun createSut() = PrivatePaykitRepo( ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, pubkyService = pubkyService, - keychain = keychain, cacheStore = cacheStore, settingsStore = settingsStore, addressReservationRepo = addressReservationRepo, @@ -1377,113 +395,55 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { clock = clock, ) - private suspend fun restoreContactBackup() { - whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) - val remoteEndpoints = mapOf( - MethodId.P2wpkh.rawValue to PublicPaykitRepo.serializePayload("bcrt1qprivate"), - ) - sut.restoreBackup( - mapOf( - CONTACT_KEY to PrivatePaykitContactLinkBackupV1( - publicKey = CONTACT_KEY, - linkSnapshotHex = LINK_SNAPSHOT, - remoteEndpoints = remoteEndpoints, - linkCompletedAt = NOW_SECONDS - 60, - ), - ), - ).getOrThrow() - } - - private suspend fun rememberSavedContact() { - sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() - } - - private suspend fun stubPendingFreshHandshake() { - whenever(pubkyService.initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY)).thenReturn(HANDSHAKE_ID) - whenever(pubkyService.advanceHandshake(HANDSHAKE_ID)) - .thenAnswer { throw PrivatePaykitTestError("transition_transport failed isHandshake") } - whenever(pubkyService.serializeEncryptedLinkHandshake(HANDSHAKE_ID)).thenReturn(UPDATED_HANDSHAKE_SNAPSHOT) - } - - private fun recoveryMarkerJson( - writerPublicKey: String, - readerPublicKey: String, - stage: String, - attemptId: String, - createdAt: Long, - ): String { - val path = recoveryMarkerPath(writerPublicKey, readerPublicKey) - return """{"version":1,"path":"$path","stage":"$stage","attemptId":"$attemptId","createdAt":$createdAt}""" - } - - private fun recoveryMarkerPath(writerPublicKey: String, readerPublicKey: String): String { - val writer = checkNotNull(PubkyPublicKeyFormat.normalized(writerPublicKey)) - val reader = checkNotNull(PubkyPublicKeyFormat.normalized(readerPublicKey)) - val material = "bitkit-private-paykit-recovery-v1|$writer|$reader" - val markerId = MessageDigest.getInstance("SHA-256") - .digest(material.encodeToByteArray()) - .joinToString(separator = "") { "%02x".format(it) } - return "/pub/paykit/v0/private-recovery/$markerId.json" - } - - private fun startForegroundWithSharingEnabled() { - settingsData.value = SettingsData( - sharesPublicPaykitEndpoints = true, - sharesPrivatePaykitEndpoints = true, - ) - whenever(walletRepo.walletExists()).thenReturn(true) - App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } - } + private fun resolution(vararg endpoints: PaykitResolvedPaymentEndpoint) = PaykitContactPaymentResolution( + payableEndpoints = endpoints.toList(), + ) - private fun paymentDetails( - id: String, - status: PaymentStatus, - ) = PaymentDetails( - id = id, - kind = PaymentKind.Bolt11( - hash = id, - preimage = null, - secret = null, - description = "", - bolt11 = PRIVATE_BOLT11, - ), - amountMsat = 1000uL, - feePaidMsat = 0uL, - direction = PaymentDirection.OUTBOUND, - status = status, - latestUpdateTimestamp = NOW_SECONDS.toULong(), + private fun resolvedEndpoint( + methodId: MethodId, + value: String, + source: PaymentEndpointSource = PaymentEndpointSource.PRIVATE_PAYMENT_LIST, + ): PaykitResolvedPaymentEndpoint { + return PaykitResolvedPaymentEndpoint( + counterparty = CONTACT_KEY, + source = source, + identifier = methodId.rawValue, + payload = PublicPaykitRepo.serializePayload(value), + ) + } + + private fun privateListDeliveryReport( + queuedCounterparties: List = emptyList(), + clearedCounterparties: List = emptyList(), + failedToQueue: List = emptyList(), + ) = PrivatePaymentListDeliveryReport( + queued = queuedCounterparties.map { + PrivatePaymentListSyncChange( + counterparty = it, + outboundMessageId = null, + error = null, + ) + }, + cleared = clearedCounterparties.map { + PrivatePaymentListSyncChange( + counterparty = it, + outboundMessageId = null, + error = null, + ) + }, + failedToQueue = failedToQueue, + failedToDeliver = emptyList(), ) private fun lightningInvoice(bolt11: String, paymentHash: ByteArray) = LightningInvoice( bolt11 = bolt11, paymentHash = paymentHash, amountSatoshis = 0uL, - timestampSeconds = 0u, - expirySeconds = 86_400u, + timestampSeconds = NOW_SECONDS.toULong(), + expirySeconds = PRIVATE_BOLT11_EXPIRY_SECONDS.toULong(), isExpired = false, description = "", networkType = NetworkType.REGTEST, payeeNodeId = null, ) - - private fun privatePaymentsPayload(entries: List) = FfiPrivatePaymentsPayload( - reference = "550e8400-e29b-41d4-a716-446655440000", - entries = entries, - ) - - private fun secretStateJson( - linkSnapshotHex: String? = LINK_SNAPSHOT, - handshakeSnapshotHex: String? = null, - ): String { - val linkSnapshot = linkSnapshotHex?.let { "\"$it\"" } ?: "null" - val handshakeSnapshot = handshakeSnapshotHex?.let { "\"$it\"" } ?: "null" - return """ - {"contacts":{"$CONTACT_KEY":{"linkSnapshotHex":$linkSnapshot,"handshakeSnapshotHex":$handshakeSnapshot}}} - """.trimIndent() - } } - -private class PrivatePaykitTestError( - message: String, - cause: Throwable? = null, -) : AppError(message, cause) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 63b5a9f66c..4d6af2f785 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,7 +4,11 @@ import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache -import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.ContactProfileResolution +import com.synonym.paykit.ContactProfileSource +import com.synonym.paykit.ContactRecord +import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -13,20 +17,17 @@ import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce -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.PrivatePaykitCacheData -import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.env.Env import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult @@ -41,15 +42,15 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds -import com.synonym.bitkitcore.PubkyProfile as CorePubkyProfile +import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @Suppress("LargeClass") class PubkyRepoTest : BaseUnitTest() { companion object { // Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars) - private const val VALID_CONTACT_KEY_A = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - private const val VALID_CONTACT_KEY_B = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" - private const val VALID_SELF_KEY = "pubkyot1uwisza345h769ybndrfg8ejkmcpqxybndrfg8ejkmcpqxot1u" + private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } private lateinit var sut: PubkyRepo @@ -59,27 +60,19 @@ class PubkyRepoTest : BaseUnitTest() { private val imageLoader = mock() private val pubkyStore = mock() private val settingsStore = mock() - private val privatePaykitCacheStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) - private val privatePaykitCacheFlow = MutableStateFlow(PrivatePaykitCacheData()) @Before fun setUp() = runBlocking { settingsFlow.value = SettingsData() - privatePaykitCacheFlow.value = PrivatePaykitCacheData() whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) Unit } - whenever(privatePaykitCacheStore.data).thenReturn(privatePaykitCacheFlow) - whenever { privatePaykitCacheStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) - privatePaykitCacheFlow.value = transform(privatePaykitCacheFlow.value) - Unit - } sut = createSut() } @@ -90,7 +83,6 @@ class PubkyRepoTest : BaseUnitTest() { imageLoader = imageLoader, pubkyStore = pubkyStore, settingsStore = settingsStore, - privatePaykitCacheStore = privatePaykitCacheStore, httpClient = mock(), ) @@ -129,14 +121,13 @@ class PubkyRepoTest : BaseUnitTest() { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - val ffiProfile = mock() - whenever(ffiProfile.name).thenReturn("User") - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + val pubkyProfile = createPubkyProfile(name = "User") + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) sut.startAuthentication() val result = sut.completeAuthentication() @@ -144,46 +135,6 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) - verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, testSecret) } - } - - @Test - fun `completeAuthentication should clear managed secret key`() = test { - val testSecret = "session_secret" - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - val ffiProfile = createFfiProfile(name = "User") - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) - - sut.startAuthentication() - val result = sut.completeAuthentication() - - assertTrue(result.isSuccess) - verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - } - - @Test - fun `completeAuthentication should clear profile recovery marker`() = test { - val testSecret = "session_secret" - val testPk = VALID_SELF_KEY.removePrefix("pubky") - privatePaykitCacheFlow.value = PrivatePaykitCacheData(profileRecoveryPending = true) - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - val ffiProfile = createFfiProfile(name = "User") - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) - - sut.startAuthentication() - val result = sut.completeAuthentication() - - assertTrue(result.isSuccess) - assertFalse(privatePaykitCacheFlow.value.profileRecoveryPending) } @Test @@ -191,18 +142,18 @@ class PubkyRepoTest : BaseUnitTest() { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - val ffiProfile = createFfiProfile(name = "User") - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) + val pubkyProfile = createPubkyProfile(name = "User") + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) sut.startAuthentication() val result = sut.completeAuthentication() assertTrue(result.isSuccess) - verify(pubkyService).sessionList(testSecret, Env.contactsBasePath) + verify(pubkyService).contactRecords() } @Test @@ -286,12 +237,12 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `handleAuthCallback should keep active auth after missing cancel nonce`() = test { - val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) sut.startAuthentication() val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = null)) @@ -305,12 +256,12 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `handleAuthCallback should keep active auth after missing error nonce`() = test { - val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) sut.startAuthentication() val callbackResult = sut.handleAuthCallback( @@ -326,12 +277,12 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `handleAuthCallback should keep active auth after invalid cancel nonce`() = test { - val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) sut.startAuthentication() val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid")) @@ -345,12 +296,12 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `handleAuthCallback should keep active auth after invalid error nonce`() = test { - val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(testSecret) - whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(testPk) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) sut.startAuthentication() val callbackResult = sut.handleAuthCallback( @@ -384,12 +335,14 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } - val ffiProfile = mock() - whenever(ffiProfile.name).thenReturn("Profile Name") - whenever(ffiProfile.bio).thenReturn("A bio") - whenever(ffiProfile.image).thenReturn("pubky://image_uri") - whenever(ffiProfile.status).thenReturn("active") - whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile) + val pubkyProfile = createPubkyProfile( + name = "Profile Name", + bio = "A bio", + image = "pubky://image_uri", + status = "active", + ) + whenever(pubkyService.resolveContactProfile(pk, true)) + .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) sut.loadProfile() @@ -408,7 +361,7 @@ class PubkyRepoTest : BaseUnitTest() { assertNotNull(existingProfile) val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } - whenever(pubkyService.getProfile(pk)).thenAnswer { throw TestAppError("Network error") } + whenever(pubkyService.resolveContactProfile(pk, true)).thenAnswer { throw TestAppError("Network error") } sut.loadProfile() @@ -420,7 +373,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `loadProfile should return early when no public key`() = test { sut.loadProfile() - verify(pubkyService, never()).getProfile(any()) + verify(pubkyService, never()).resolveContactProfile(any(), any()) } @Test @@ -428,11 +381,9 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } - val ffiProfile = mock() - whenever(ffiProfile.name).thenReturn("Cached Name") - whenever(ffiProfile.bio).thenReturn("") - whenever(ffiProfile.image).thenReturn("pubky://cached_image") - whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile) + val pubkyProfile = createPubkyProfile(name = "Cached Name", image = "pubky://cached_image") + whenever(pubkyService.resolveContactProfile(pk, true)) + .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) sut.loadProfile() @@ -476,22 +427,13 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `removeBitkitPaymentEndpoints removes only bitkit managed endpoints`() = test { + fun `removeBitkitPaymentEndpoints delegates to Pubky service cleanup`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) - whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenReturn( - listOf( - paymentEntry(MethodId.Bolt11), - paymentEntry(MethodId.Lnurl), - paymentEntry(MethodId.P2tr), - ), - ) val result = sut.removeBitkitPaymentEndpoints() assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { removePaymentEndpoint(MethodId.Bolt11.rawValue) } - verifyBlocking(pubkyService) { removePaymentEndpoint(MethodId.P2tr.rawValue) } - verifyBlocking(pubkyService, never()) { removePaymentEndpoint(MethodId.Lnurl.rawValue) } + verifyBlocking(pubkyService) { removeBitkitPaymentEndpoints() } } @Test @@ -504,7 +446,7 @@ class PubkyRepoTest : BaseUnitTest() { publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = 123L, ) - whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Cleanup failed") } + whenever(pubkyService.removeBitkitPaymentEndpoints()).thenAnswer { throw TestAppError("Cleanup failed") } val result = sut.signOut() @@ -525,10 +467,9 @@ class PubkyRepoTest : BaseUnitTest() { fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() val pk = checkNotNull(sut.publicKey.value) - val ffiProfile = mock() - whenever(ffiProfile.name).thenReturn("Test") - whenever(ffiProfile.image).thenReturn("pubky://image_uri") - whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile) + val pubkyProfile = createPubkyProfile(name = "Test", image = "pubky://image_uri") + whenever(pubkyService.resolveContactProfile(pk, true)) + .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) sut.loadProfile() val memoryCache = mock() @@ -564,22 +505,14 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession, newSession) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - whenever(pubkyService.sessionList(expiredSession, Env.contactsBasePath)).thenReturn(emptyList()) - whenever(pubkyService.sessionList(newSession, Env.contactsBasePath)).thenReturn(emptyList()) - whenever( - pubkyService.sessionDelete(expiredSession, Env.profilePath) - ).thenAnswer { - throw TestAppError("Expired") - } - whenever(pubkyService.signIn(secretKey)).thenReturn(newSession) - whenever(pubkyService.importSession(newSession)).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") }.thenReturn(Unit) + whenever(pubkyService.signIn(secretKey)).thenReturn(Unit) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) val result = sut.deleteProfileWithSessionRetry() assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { sessionDelete(expiredSession, Env.profilePath) } - verifyBlocking(pubkyService) { sessionDelete(newSession, Env.profilePath) } - verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, newSession) } + verifyBlocking(pubkyService, times(2)) { deletePaykitProfile() } } @Test @@ -588,17 +521,12 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - whenever(pubkyService.sessionList(expiredSession, Env.contactsBasePath)).thenReturn(emptyList()) - whenever( - pubkyService.sessionDelete(expiredSession, Env.profilePath) - ).thenAnswer { - throw TestAppError("Expired") - } + whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") } val result = sut.deleteProfileWithSessionRetry() assertTrue(result.isFailure) - verifyBlocking(pubkyService) { sessionDelete(expiredSession, Env.profilePath) } + verifyBlocking(pubkyService) { deletePaykitProfile() } verifyBlocking(pubkyService, never()) { signIn(any()) } } @@ -615,7 +543,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `clearPendingImport should only clear temporary import state`() = test { + fun `clearPendingImport should only clear pending import state`() = test { authenticateForTesting() val existingContact = PubkyProfile( publicKey = VALID_CONTACT_KEY_B, @@ -631,8 +559,8 @@ class PubkyRepoTest : BaseUnitTest() { sut.addContact(existingContact.publicKey, existingProfile = existingContact) whenever(pubkyService.getContacts(publicKey)).thenReturn(listOf(pendingContactKey)) - val pendingContactProfile = createFfiProfile(name = "Pending Contact") - whenever(pubkyService.getProfile(pendingContactKey)).thenReturn(pendingContactProfile) + whenever(pubkyService.resolveContactProfile(pendingContactKey, true)) + .thenReturn(createResolution(pendingContactKey, paykitProfile = createPaykitProfile("Pending Contact"))) val prepareResult = sut.prepareImport() @@ -714,11 +642,12 @@ class PubkyRepoTest : BaseUnitTest() { fun `initialize should restore saved session with prefixed public key`() = test { val session = "saved_session" val unprefixedPublicKey = VALID_SELF_KEY.removePrefix("pubky") - val ffiProfile = createFfiProfile(name = "Restored User") + val pubkyProfile = createPubkyProfile(name = "Restored User") whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) sut.initialize() @@ -729,20 +658,19 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `initialize should restore session from local secret key when saved session is missing`() = test { val secretKey = "local_secret" - val session = "new_session" val publicKey = VALID_SELF_KEY.removePrefix("pubky") - val ffiProfile = createFfiProfile(name = "Recovered User") + val pubkyProfile = createPubkyProfile(name = "Recovered User") whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - whenever(pubkyService.signIn(secretKey)).thenReturn(session) - whenever(pubkyService.importSession(session)).thenReturn(publicKey) - whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(pubkyService.signIn(secretKey)).thenReturn(Unit) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(publicKey) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) sut.initialize() assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) - verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } } @Test @@ -762,17 +690,15 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" - val session = "new_session" whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - whenever(pubkyService.signIn(secretKey)).thenReturn(session) - whenever(pubkyService.importSession(session)).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.signIn(secretKey)).thenReturn(Unit) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) val result = sut.refreshSessionIfPossible() assertEquals(true, result.getOrNull()) assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertTrue(sut.isAuthenticated.value) - verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, session) } } @Test @@ -793,17 +719,21 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) whenever(pubkyService.mnemonicToSeed("test mnemonic", null)).thenReturn(seed) whenever(pubkyService.deriveSecretKey(seed)).thenReturn("derived_secret") + whenever(pubkyService.signIn("derived_secret")).thenReturn(Unit) + whenever(pubkyService.publicKeyFromSecret("derived_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) val result = sut.restoreSessionBackupState( PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed), ) assertTrue(result.isSuccess) - verifyBlocking(keychain) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, "derived_secret") } + assertEquals(VALID_SELF_KEY, sut.publicKey.value) } @Test fun `restoreSessionBackupState should save external session backups`() = test { + whenever(pubkyService.importExternalSession("external_session")).thenReturn(VALID_SELF_KEY) + val result = sut.restoreSessionBackupState( PubkySessionBackupV1( kind = PubkySessionBackupKind.ExternalSession, @@ -812,38 +742,30 @@ class PubkyRepoTest : BaseUnitTest() { ) assertTrue(result.isSuccess) - verifyBlocking(keychain) { upsertString(Keychain.Key.PAYKIT_SESSION.name, "external_session") } + assertEquals(VALID_SELF_KEY, sut.publicKey.value) } @Test - fun `restoreSessionBackupState should keep current session when backup has no pubky state`() = test { + fun `restoreSessionBackupState should clear current session when backup has no pubky state`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) clearInvocations(pubkyService, keychain) val result = sut.restoreSessionBackupState(null) assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - assertEquals(VALID_SELF_KEY, sut.publicKey.value) - verifyBlocking(pubkyService, never()) { forceSignOut() } - verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } - verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + assertFalse(sut.isAuthenticated.value) + assertNull(sut.publicKey.value) + verifyBlocking(pubkyService) { clearSessionAccess() } + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } @Test fun `loadContacts should populate contacts on success`() = test { authenticateForTesting() val contactKey = "pubkycontact1" - val contactPath = "${Env.contactsBasePath}$contactKey" - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenReturn(listOf(contactPath)) - - val json = """{"name":"Alice","bio":"Hello"}""" - val pk = checkNotNull(sut.publicKey.value) - val strippedPk = pk.removePrefix("pubky") - whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey")) - .thenReturn(json) + whenever(pubkyService.contactRecords()) + .thenReturn(listOf(createContactRecord(contactKey, profile = createPaykitProfile("Alice", bio = "Hello")))) sut.loadContacts() @@ -889,17 +811,17 @@ class PubkyRepoTest : BaseUnitTest() { secret = oldSecret, profileName = "Initial Old", ) - whenever(pubkyService.completeAuth()).thenReturn(newSecret) - whenever(pubkyService.importSession(newSecret)).thenReturn(newPublicKey) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret) - whenever(pubkyService.sessionList(newSecret, Env.contactsBasePath)).thenReturn(emptyList()) - val staleProfile = createFfiProfile(name = "Stale Old") - whenever(pubkyService.getProfile(oldPublicKey.ensurePubkyPrefixForTest())).thenAnswer { + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + val staleProfile = createPubkyProfile(name = "Stale Old") + whenever(pubkyService.resolveContactProfile(oldPublicKey.ensurePubkyPrefixForTest(), true)).thenAnswer { runBlocking { startAuthForTesting() sut.completeAuthentication() } - staleProfile + createResolution(oldPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = staleProfile) } sut.loadProfile() @@ -924,8 +846,6 @@ class PubkyRepoTest : BaseUnitTest() { status = null, ) val staleContactKey = "pubkystale-contact" - val staleContactPath = "${Env.contactsBasePath}$staleContactKey" - val staleContactUri = "pubky://$oldPublicKey${Env.contactsBasePath}$staleContactKey" authenticateForTesting( publicKey = oldPublicKey, @@ -934,18 +854,18 @@ class PubkyRepoTest : BaseUnitTest() { ) sut.addContact(existingContact.publicKey, existingProfile = existingContact) - whenever(pubkyService.completeAuth()).thenReturn(newSecret) - whenever(pubkyService.importSession(newSecret)).thenReturn(newPublicKey) - val newProfile = createFfiProfile(name = "New User") - whenever(pubkyService.getProfile(newPublicKey.ensurePubkyPrefixForTest())).thenReturn(newProfile) + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey) + val newProfile = createPubkyProfile(name = "New User") + whenever(pubkyService.resolveContactProfile(newPublicKey.ensurePubkyPrefixForTest(), true)) + .thenReturn(createResolution(newPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = newProfile)) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(oldSecret) - whenever(pubkyService.sessionList(oldSecret, Env.contactsBasePath)).thenReturn(listOf(staleContactPath)) - whenever(pubkyService.fetchFileString(staleContactUri)).thenAnswer { + whenever(pubkyService.contactRecords()).thenAnswer { runBlocking { startAuthForTesting() sut.completeAuthentication() } - """{"name":"Stale Contact","bio":""}""" + listOf(createContactRecord(staleContactKey, profile = createPaykitProfile("Stale Contact"))) } sut.loadContacts() @@ -961,21 +881,15 @@ class PubkyRepoTest : BaseUnitTest() { fun `loadContacts should return early when no public key`() = test { sut.loadContacts() - verify(pubkyService, never()).sessionList(any(), any()) + verify(pubkyService, never()).contactRecords() } @Test fun `loadContacts should use placeholder when profile fetch fails`() = test { authenticateForTesting() val contactKey = "pubkycontact2" - val contactPath = "${Env.contactsBasePath}$contactKey" - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenReturn(listOf(contactPath)) - - val pk = checkNotNull(sut.publicKey.value) - val strippedPk = pk.removePrefix("pubky") - whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey")) + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(contactKey))) + whenever(pubkyService.resolveContactProfile(contactKey, true)) .thenAnswer { throw TestAppError("Network error") } sut.loadContacts() @@ -987,11 +901,9 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `loadContacts should treat missing contacts directory as empty`() = test { + fun `loadContacts should treat empty SDK contact records as empty`() = test { authenticateForTesting() - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenAnswer { throw TestAppError("Directory Not Found (404)") } + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) sut.loadContacts() @@ -1000,30 +912,25 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `fetchContactProfile should return bitkit profile when available`() = test { + fun `fetchContactProfile should return paykit profile when available`() = test { val contactKey = VALID_CONTACT_KEY_A - val strippedKey = contactKey.removePrefix("pubky") - val json = """{"name":"Bob","bio":"Bio"}""" - whenever(pubkyService.fetchFileString("pubky://$strippedKey${Env.profilePath}")) - .thenReturn(json) + whenever(pubkyService.resolveContactProfile(contactKey, true)) + .thenReturn(createResolution(contactKey, paykitProfile = createPaykitProfile("Bob", bio = "Bio"))) val result = sut.fetchContactProfile(contactKey) assertTrue(result.isSuccess) assertEquals("Bob", result.getOrNull()?.name) - verify(pubkyService, never()).getProfile(contactKey) + assertEquals("Bio", result.getOrNull()?.bio) + verify(pubkyService).resolveContactProfile(contactKey, true) } @Test - fun `fetchContactProfile should fall back to pubky profile when bitkit profile is missing`() = test { + fun `fetchContactProfile should use pubky profile fallback when SDK resolves one`() = test { val contactKey = VALID_CONTACT_KEY_A - val strippedKey = contactKey.removePrefix("pubky") - val contactProfile = mock() - whenever(pubkyService.fetchFileString("pubky://$strippedKey${Env.profilePath}")) - .thenAnswer { throw TestAppError("Missing bitkit profile") } - whenever(contactProfile.name).thenReturn("Bob") - whenever(contactProfile.bio).thenReturn("Bio") - whenever(pubkyService.getProfile(contactKey)).thenReturn(contactProfile) + val contactProfile = createPubkyProfile(name = "Bob", bio = "Bio") + whenever(pubkyService.resolveContactProfile(contactKey, true)) + .thenReturn(createResolution(contactKey, pubkyProfile = contactProfile)) val result = sut.fetchContactProfile(contactKey) @@ -1034,10 +941,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `fetchContactProfile should fall back to placeholder when remote profile is missing`() = test { val contactKey = VALID_CONTACT_KEY_A - val strippedKey = contactKey.removePrefix("pubky") - whenever(pubkyService.fetchFileString("pubky://$strippedKey${Env.profilePath}")) - .thenAnswer { throw TestAppError("Missing bitkit profile") } - whenever(pubkyService.getProfile(contactKey)).thenAnswer { throw TestAppError("Profile not found") } + whenever(pubkyService.resolveContactProfile(contactKey, true)).thenReturn(null) val result = sut.fetchContactProfile(contactKey) @@ -1064,57 +968,42 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `uploadAvatar should use session when managed key belongs to another pubky`() = test { + fun `uploadAvatar should publish avatar through Paykit SDK`() = test { val session = "test_session" val currentPublicKey = "pubkyalice" - val staleSecretKey = "stale_secret" - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(staleSecretKey) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) - whenever(pubkyService.publicKeyFromSecret(staleSecretKey)).thenReturn("pubkybob") + whenever(pubkyService.uploadProfileAvatar(any(), any())).thenReturn("pubky://avatar") authenticateForTesting(publicKey = currentPublicKey, secret = session, profileName = "Alice") - clearInvocations(keychain) val result = sut.uploadAvatar(byteArrayOf(1, 2, 3)) assertTrue(result.isSuccess) - verifyBlocking(pubkyService, never()) { putWithSecretKey(any(), any(), any()) } - verifyBlocking(pubkyService) { sessionPut(eq(session), any(), any()) } - verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + assertEquals("pubky://avatar", result.getOrNull()) + verifyBlocking(pubkyService) { uploadProfileAvatar(any(), any()) } } @Test - fun `uploadAvatar should use managed key when it matches current pubky`() = test { - val session = "test_session" + fun `uploadAvatar should fail when session is missing`() = test { val currentPublicKey = "pubkyalice" - val secretKey = "managed_secret" - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) - whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn("alice") + val session = "test_session" authenticateForTesting(publicKey = currentPublicKey, secret = session, profileName = "Alice") + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) val result = sut.uploadAvatar(byteArrayOf(1, 2, 3)) - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { putWithSecretKey(eq(secretKey), any(), any()) } - verifyBlocking(pubkyService, never()) { sessionPut(eq(session), any(), any()) } + assertTrue(result.isFailure) + verifyBlocking(pubkyService, never()) { uploadProfileAvatar(any(), any()) } } @Test fun `signOut should clear contacts`() = test { authenticateForTesting() val contactKey = "pubkycontact4" - val contactPath = "${Env.contactsBasePath}$contactKey" - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenReturn(listOf(contactPath)) - - val pk = checkNotNull(sut.publicKey.value) - val strippedPk = pk.removePrefix("pubky") - val json = """{"name":"Charlie","bio":""}""" - whenever(pubkyService.fetchFileString("pubky://$strippedPk${Env.contactsBasePath}$contactKey")) - .thenReturn(json) + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(contactKey, profile = createPaykitProfile("Charlie"))), + ) sut.loadContacts() assertEquals(1, sut.contacts.value.size) @@ -1130,8 +1019,8 @@ class PubkyRepoTest : BaseUnitTest() { val publicKey = checkNotNull(sut.publicKey.value) val pendingContactKey = "pubkypending-contact" whenever(pubkyService.getContacts(publicKey)).thenReturn(listOf(pendingContactKey)) - val pendingContactProfile = createFfiProfile(name = "Pending Contact") - whenever(pubkyService.getProfile(pendingContactKey)).thenReturn(pendingContactProfile) + whenever(pubkyService.resolveContactProfile(pendingContactKey, true)) + .thenReturn(createResolution(pendingContactKey, paykitProfile = createPaykitProfile("Pending Contact"))) sut.prepareImport() assertNotNull(sut.pendingImportProfile.value) @@ -1170,23 +1059,15 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `loadContacts should extract contact key from path`() = test { + fun `loadContacts should use contact label when profile is unavailable`() = test { authenticateForTesting() val contactKey = "pubkyabc123" - val contactPath = "${Env.contactsBasePath}$contactKey" - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") - whenever(pubkyService.sessionList("test_secret", Env.contactsBasePath)) - .thenReturn(listOf(contactPath)) - - val pk = checkNotNull(sut.publicKey.value) - val strippedPk = pk.removePrefix("pubky") - val expectedUri = "pubky://$strippedPk${Env.contactsBasePath}$contactKey" - val json = """{"name":"Extracted","bio":""}""" - whenever(pubkyService.fetchFileString(expectedUri)).thenReturn(json) + whenever(pubkyService.contactRecords()) + .thenReturn(listOf(createContactRecord(contactKey, label = "Extracted"))) + whenever(pubkyService.resolveContactProfile(contactKey, true)).thenReturn(null) sut.loadContacts() - verify(pubkyService).fetchFileString(expectedUri) assertEquals("Extracted", sut.contacts.value.first().name) assertEquals(contactKey, sut.contacts.value.first().publicKey) } @@ -1197,36 +1078,84 @@ class PubkyRepoTest : BaseUnitTest() { profileName: String = "Test", ) { val prefixedPublicKey = publicKey.ensurePubkyPrefixForTest() - whenever { pubkyService.completeAuth() }.thenReturn(secret) - whenever { pubkyService.importSession(secret) }.thenReturn(publicKey) - val ffiProfile = createFfiProfile(name = profileName) - whenever { pubkyService.getProfile(prefixedPublicKey) }.thenReturn(ffiProfile) + whenever { pubkyService.completeAuth() }.thenReturn(Unit) + whenever { pubkyService.currentPublicKey() }.thenReturn(publicKey) + val pubkyProfile = createPubkyProfile(name = profileName) + whenever { pubkyService.resolveContactProfile(prefixedPublicKey, true) } + .thenReturn(createResolution(prefixedPublicKey, pubkyProfile = pubkyProfile)) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret) - whenever { pubkyService.sessionList(secret, Env.contactsBasePath) }.thenReturn(emptyList()) - whenever { pubkyService.getPaymentList(prefixedPublicKey) }.thenReturn(emptyList()) + whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) startAuthForTesting() sut.completeAuthentication() } - private fun paymentEntry(methodId: MethodId) = FfiPaymentEntry( - methodId = methodId.rawValue, - endpointData = """{"value":"value"}""", - ) - private suspend fun startAuthForTesting(authUri: String = "auth_uri") { whenever { pubkyService.startAuth() }.thenReturn(authUri) sut.startAuthentication() } - private fun createFfiProfile(name: String): CorePubkyProfile { - val ffiProfile = mock() - whenever(ffiProfile.name).thenReturn(name) - whenever(ffiProfile.bio).thenReturn("") - whenever(ffiProfile.image).thenReturn(null) - whenever(ffiProfile.status).thenReturn(null) - return ffiProfile - } + private fun createPubkyProfile( + name: String = "Test", + bio: String = "", + image: String? = null, + status: String? = null, + ) = SdkPubkyProfile( + name = name, + bio = bio, + image = image, + links = emptyList(), + status = status, + ) + + private fun createPaykitProfile( + name: String, + bio: String = "", + image: String? = null, + ) = PubkyProfile( + publicKey = "", + name = name, + bio = bio, + imageUrl = image, + links = emptyList(), + tags = emptyList(), + status = null, + ).toProfileData().toPaykitProfile() + + private fun createContactRecord( + publicKey: String, + label: String? = null, + profile: PaykitProfile? = null, + ) = ContactRecord( + publicKey = publicKey, + label = label, + profile = profile, + profileFetchedAt = null, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-01T00:00:00Z", + publicContactMarkerStatus = PublicationStatus.NOT_PUBLISHED, + publicContactPublishedAt = null, + publicContactRemovedAt = null, + publicContactLastError = null, + ) + + private fun createResolution( + publicKey: String, + paykitProfile: PaykitProfile? = null, + pubkyProfile: SdkPubkyProfile? = null, + ) = ContactProfileResolution( + publicKey = publicKey, + source = if (paykitProfile != null) { + ContactProfileSource.PAYKIT_PROFILE + } else { + ContactProfileSource.PUBKY_PROFILE + }, + displayName = paykitProfile?.displayName ?: pubkyProfile?.name, + imageUri = paykitProfile?.imageUri ?: pubkyProfile?.image, + paykitProfile = paykitProfile, + pubkyProfile = pubkyProfile, + fetchedAt = "2026-01-01T00:00:00Z", + ) } private class TestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index ff072126c4..6faaaa785f 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -3,25 +3,28 @@ package to.bitkit.repositories import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.EndpointSyncChange +import com.synonym.paykit.EndpointSyncReport +import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any -import org.mockito.kotlin.inOrder +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService +import to.bitkit.services.PaykitContactPaymentResolution +import to.bitkit.services.PaykitResolvedPaymentEndpoint +import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -33,12 +36,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { companion object { private const val NOW_MILLIS = 1_000L private const val PUBLIC_BOLT11_EXPIRY_SECONDS = 86_400u + private const val PUBLIC_BOLT11 = "lnbcrt1public" } private val pubkyRepo = mock() private val walletRepo = mock() private val lightningRepo = mock() private val coreService = mock() + private val paykitSdkService = mock() private val settingsStore = mock() private val clock = mock() @@ -49,7 +54,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { private lateinit var sut: PublicPaykitRepo @Before - fun setUp() { + fun setUp() = test { sut = createRepo() settingsFlow.value = SettingsData() publicKey.value = "pubkyself" @@ -59,12 +64,10 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(walletRepo.walletState).thenReturn(walletState) whenever(settingsStore.data).thenReturn(settingsFlow) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) - whenever { pubkyRepo.setPaymentEndpoint(any(), any()) }.thenReturn(Result.success(Unit)) - whenever { pubkyRepo.removePaymentEndpoint(any()) }.thenReturn(Result.success(Unit)) + whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn(syncReport()) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) - Unit } PublicPaykitRepo.lightningRouteHintsValidator = { true } } @@ -75,57 +78,31 @@ class PublicPaykitRepoTest : BaseUnitTest() { } @Test - fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete bitkit endpoints`() = test { - walletState.value = WalletState( - onchainAddress = "bc1ptest", - bolt11 = "lnbc1user", - ) + fun `syncCurrentPublishedEndpoints configures SDK public endpoints`() = test { + walletState.value = WalletState(onchainAddress = "bc1ptest") stubPublicInvoice("lnbc1public", byteArrayOf(1, 2, 3)) - whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( - Result.success( - listOf( - paymentEntry(MethodId.Bolt11, "lnbc1old"), - paymentEntry(MethodId.Lnurl, "lnurl1external"), - paymentEntry(MethodId.P2pkh, "1obsolete"), - ), - ), - ) val result = sut.syncCurrentPublishedEndpoints() assertTrue(result.isSuccess) assertEquals("lnbc1public", settingsFlow.value.publicPaykitBolt11) assertEquals("010203", settingsFlow.value.publicPaykitBolt11PaymentHash) - inOrder(pubkyRepo) { - verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1public"}""") - verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") - verify(pubkyRepo).getPaymentList("pubkyself") - verify(pubkyRepo).removePaymentEndpoint(MethodId.P2pkh.rawValue) - } - verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) - verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Bolt11.rawValue) - verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) - } - @Test - fun `syncPublishedEndpoints removes bitkit managed endpoints and preserves lnurl`() = test { - setSettings( - SettingsData( - publicPaykitBolt11 = "lnbc1old", - publicPaykitBolt11PaymentHash = "010203", - publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), - publicPaykitCleanupPending = true, - ), + val captor = argumentCaptor>() + verifyBlocking(paykitSdkService) { syncPublicEndpoints(captor.capture()) } + assertEquals( + listOf(MethodId.Bolt11, MethodId.P2tr), + captor.firstValue.map { it.methodId }, ) + } - whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( - Result.success( - listOf( - paymentEntry(MethodId.Bolt11, "lnbc1old"), - paymentEntry(MethodId.Lnurl, "lnurl1external"), - paymentEntry(MethodId.P2tr, "bc1pold"), - ), - ), + @Test + fun `syncPublishedEndpoints remove clears SDK public endpoints and metadata`() = test { + settingsFlow.value = SettingsData( + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + publicPaykitCleanupPending = true, ) val result = sut.syncPublishedEndpoints(publish = false) @@ -133,307 +110,67 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("", settingsFlow.value.publicPaykitBolt11) assertEquals(false, settingsFlow.value.publicPaykitCleanupPending) - verify(pubkyRepo).removePaymentEndpoint(MethodId.Bolt11.rawValue) - verify(pubkyRepo).removePaymentEndpoint(MethodId.P2tr.rawValue) - verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) + verifyBlocking(paykitSdkService) { syncPublicEndpoints(emptyList()) } } @Test - fun `syncCurrentPublishedEndpoints reuses fresh public bolt11`() = test { - setSettings( - SettingsData( - publicPaykitBolt11 = "lnbc1cached", - publicPaykitBolt11PaymentHash = "010203", - publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + fun `syncCurrentPublishedEndpoints returns publication failed when SDK sync fails`() = test { + walletState.value = WalletState(onchainAddress = "bc1ptest") + whenever(paykitSdkService.syncPublicEndpoints(any())).thenReturn( + syncReport( + failed = listOf( + EndpointSyncChange( + identifier = MethodId.P2tr.rawValue, + status = PublicationStatus.PENDING_PUBLICATION, + error = "failed", + ), + ), ), ) - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) - - val result = sut.syncCurrentPublishedEndpoints() + val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() - assertTrue(result.isSuccess) - verify(lightningRepo, never()).createInvoice( - amountSats = null, - description = "", - expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, - ) - verify(coreService, never()).decode(any()) - verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1cached"}""") + assertEquals(PublicPaykitError.PublicationFailed, error) } @Test fun `syncCurrentPublishedEndpoints returns NoSupportedEndpoint when endpoint is required`() = test { - setSettings(SettingsData(publicPaykitOnchainEnabled = false)) + settingsFlow.value = SettingsData(publicPaykitOnchainEnabled = false) whenever(lightningRepo.canReceive()).thenReturn(false) val error = sut.syncCurrentPublishedEndpoints(requireEndpoint = true).exceptionOrNull() assertEquals(PublicPaykitError.NoSupportedEndpoint, error) - verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) - verify(pubkyRepo, never()).removePaymentEndpoint(any()) - } - - @Test - fun `refreshPublishedBolt11ForPayment rotates paid public bolt11`() = test { - setSettings( - SettingsData( - sharesPublicPaykitEndpoints = true, - publicPaykitBolt11 = "lnbc1old", - publicPaykitBolt11PaymentHash = "010203", - publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), - ), - ) - - stubPublicInvoice("lnbc1new", byteArrayOf(4, 5, 6)) - whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( - Result.success(listOf(paymentEntry(MethodId.Bolt11, "lnbc1old"))), - ) - - val result = sut.refreshPublishedBolt11ForPayment("010203") - - assertTrue(result.isSuccess) - assertEquals("lnbc1new", settingsFlow.value.publicPaykitBolt11) - assertEquals("040506", settingsFlow.value.publicPaykitBolt11PaymentHash) - verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1new"}""") + verifyBlocking(paykitSdkService, never()) { syncPublicEndpoints(any()) } } @Test - fun `refreshPublishedBolt11ForPayment ignores unrelated payment hash`() = test { - setSettings( - SettingsData( - sharesPublicPaykitEndpoints = true, - publicPaykitBolt11 = "lnbc1old", - publicPaykitBolt11PaymentHash = "010203", - publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), - ), - ) - - val result = sut.refreshPublishedBolt11ForPayment("unrelated") - - assertTrue(result.isSuccess) - verify(lightningRepo, never()).createInvoice( - amountSats = null, - description = "", - expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, - ) - verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) - } - - @Test - fun `syncCurrentPublishedEndpoints returns SessionNotActive when no pubky session exists`() = test { - publicKey.value = null - whenever(pubkyRepo.currentPublicKey()).thenReturn(Result.success(null)) - walletState.value = WalletState(onchainAddress = "bc1ptest") - - val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() - - assertEquals(PublicPaykitError.SessionNotActive, error) - verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) - } - - @Test - fun `syncCurrentPublishedEndpoints returns InvalidPayload when public bolt11 decode is not lightning`() = test { - walletState.value = WalletState(onchainAddress = "bc1ptest") - whenever(lightningRepo.canReceive()).thenReturn(true) - whenever( - lightningRepo.createInvoice( - amountSats = null, - description = "", - expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, - ) - ).thenReturn(Result.success("not-lightning")) - whenever(coreService.decode("not-lightning")).thenReturn(mock()) - - val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() - - assertEquals(PublicPaykitError.InvalidPayload, error) - verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) - } - - @Test - fun `parseEndpoint accepts Paykit JSON payloads`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = "btc-lightning-bolt11", - endpointData = """{"value":" lnbc1test ","min":"1","max":"10","extra":"ignored"}""", - ) - - assertEquals(MethodId.Bolt11, endpoint?.methodId) - assertEquals("lnbc1test", endpoint?.value) - assertEquals("1", endpoint?.min) - assertEquals("10", endpoint?.max) - } - - @Test - fun `parseEndpoint rejects legacy lnurl pay id`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = "btc-lightning-lnurl-pay", - endpointData = """{"value":"lnurl1test"}""", - ) - - assertNull(endpoint) - } - - @Test - fun `parseEndpoint rejects raw string payloads`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = MethodId.P2wpkh.rawValue, - endpointData = "bc1qexampleaddress", - ) - - assertNull(endpoint) - } - - @Test - fun `parseEndpoint rejects lenient JSON payloads`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = MethodId.P2wpkh.rawValue, - endpointData = """{value:"bc1qexampleaddress"}""", - ) - - assertNull(endpoint) - } - - @Test - fun `parseEndpoint rejects unsupported method ids`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = "btc-lightning-bolt12", - endpointData = """{"value":"lni1test"}""", - ) - - assertNull(endpoint) - } - - @Test - fun `parseEndpoint accepts lnurl method id`() { - val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = "btc-lightning-lnurl", - endpointData = """{"value":"lnurl1test"}""", - ) - - assertEquals(MethodId.Lnurl, endpoint?.methodId) - assertEquals("lnurl1test", endpoint?.value) - } - - @Test - fun `serializePayload trims and wraps value`() { - assertEquals("""{"value":"bc1ptest"}""", PublicPaykitRepo.serializePayload(" bc1ptest ")) - } - - @Test - fun `serializePayload rejects empty values`() { - assertFailsWith { - PublicPaykitRepo.serializePayload(" ") - } - } - - @Test - fun `paymentRequest prefers bip21 with bolt11 when both are payable`() { - val request = PublicPaykitRepo.paymentRequest( - listOf( - endpoint(MethodId.Bolt11, "lnbc1test"), - endpoint(MethodId.P2tr, "bc1ptest"), - ), - ) - - assertEquals("bitcoin:bc1ptest?lightning=lnbc1test", request) - } - - @Test - fun `paymentRequest encodes bolt11 query parameter`() { - val request = PublicPaykitRepo.paymentRequest( - listOf( - endpoint(MethodId.Bolt11, "lnbc1test&label"), - endpoint(MethodId.P2tr, "bc1ptest"), - ), - ) - - assertEquals("bitcoin:bc1ptest?lightning=lnbc1test%26label", request) - } - - @Test - fun `paymentRequest prefers taproot among multiple onchain endpoints`() { - val request = PublicPaykitRepo.paymentRequest( - listOf( - endpoint(MethodId.P2wpkh, "bc1qtest"), - endpoint(MethodId.P2tr, "bc1ptest"), - ), - ) - - assertEquals("bc1ptest", request) - } - - @Test - fun `paymentRequest falls back to preferred single endpoint`() { - assertEquals( - "lnbc1test", - PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.Bolt11, "lnbc1test"))), - ) - assertEquals( - "lnurl1test", - PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.Lnurl, "lnurl1test"))), - ) - assertEquals( - "bc1ptest", - PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.P2tr, "bc1ptest"))), - ) - } - - @Test - fun `paymentRequest prefers lnurl over onchain when bolt11 is unavailable`() { - val request = PublicPaykitRepo.paymentRequest( - listOf( - endpoint(MethodId.P2tr, "bc1ptest"), - endpoint(MethodId.Lnurl, "lnurl1test"), + fun `beginPayment opens SDK resolved public endpoint`() = test { + whenever { + paykitSdkService.resolvePublicContactPayment("pubkycontact") + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.Bolt11, + value = PUBLIC_BOLT11, + ), ), ) + whenever(coreService.decode(PUBLIC_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PUBLIC_BOLT11, byteArrayOf(4, 5, 6)))) - assertEquals("lnurl1test", request) - } - - @Test - fun `paymentRequest returns empty string for empty endpoints`() { - assertEquals("", PublicPaykitRepo.paymentRequest(emptyList())) - } - - @Test - fun `method ids match Paykit grammar`() { - val pattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") - - MethodId.entries.forEach { - assertTrue(pattern.matches(it.rawValue), "Invalid method id '${it.rawValue}'") - } - } + val result = sut.beginPayment("pubkycontact").getOrThrow() - @Test - fun `onchain method ids use network rail`() { - assertEquals("btc-bitcoin-p2tr", MethodId.P2tr.rawValueForNetwork(Network.BITCOIN)) - assertEquals("btc-testnet-p2wpkh", MethodId.P2wpkh.rawValueForNetwork(Network.TESTNET)) - assertEquals("btc-regtest-p2sh", MethodId.P2sh.rawValueForNetwork(Network.REGTEST)) - assertEquals("btc-signet-p2pkh", MethodId.P2pkh.rawValueForNetwork(Network.SIGNET)) + assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) } - @Test - fun `onchainMethodId selects address method id`() { - assertEquals(MethodId.P2tr, PublicPaykitRepo.onchainMethodId("bc1ptest")) - assertEquals(MethodId.P2wpkh, PublicPaykitRepo.onchainMethodId("tb1qtest")) - assertEquals(MethodId.P2sh, PublicPaykitRepo.onchainMethodId("2test")) - assertEquals(MethodId.P2pkh, PublicPaykitRepo.onchainMethodId("1test")) - } - - private fun endpoint(methodId: MethodId, value: String) = Endpoint( - methodId = methodId, - value = value, - rawPayload = """{"value":"$value"}""", - ) - @Suppress("LongParameterList") private fun createRepo( pubkyRepo: PubkyRepo = this.pubkyRepo, walletRepo: WalletRepo = this.walletRepo, lightningRepo: LightningRepo = this.lightningRepo, coreService: CoreService = this.coreService, + paykitSdkService: PaykitSdkService = this.paykitSdkService, settingsStore: SettingsStore = this.settingsStore, clock: Clock = this.clock, ) = PublicPaykitRepo( @@ -442,19 +179,35 @@ class PublicPaykitRepoTest : BaseUnitTest() { walletRepo = walletRepo, lightningRepo = lightningRepo, coreService = coreService, + paykitSdkService = paykitSdkService, settingsStore = settingsStore, clock = clock, ) - private fun paymentEntry(methodId: MethodId, value: String) = FfiPaymentEntry( - methodId = methodId.rawValue, - endpointData = """{"value":"$value"}""", + private fun resolution(vararg endpoints: PaykitResolvedPaymentEndpoint) = PaykitContactPaymentResolution( + payableEndpoints = endpoints.toList(), ) - private fun setSettings(settings: SettingsData) { - settingsFlow.value = settings + private fun resolvedEndpoint( + methodId: MethodId, + value: String, + ): PaykitResolvedPaymentEndpoint { + return PaykitResolvedPaymentEndpoint( + counterparty = "pubkycontact", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + identifier = methodId.rawValue, + payload = PublicPaykitRepo.serializePayload(value), + ) } + private fun syncReport( + failed: List = emptyList(), + ) = EndpointSyncReport( + published = emptyList(), + removed = emptyList(), + failed = failed, + ) + private fun freshExpiryMillis() = NOW_MILLIS + 1.hours.inWholeMilliseconds private suspend fun stubPublicInvoice( @@ -476,7 +229,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { bolt11 = bolt11, paymentHash = paymentHash, amountSatoshis = 0uL, - timestampSeconds = 0u, + timestampSeconds = 0uL, expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS.toULong(), isExpired = false, description = "", diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt new file mode 100644 index 0000000000..c809c7d7e5 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -0,0 +1,16 @@ +package to.bitkit.services + +import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy +import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PublicContactSharingPolicy +import org.junit.Test +import kotlin.test.assertEquals + +class PaykitSdkServiceTest { + @Test + fun `config scopes public endpoint sync to Bitkit managed endpoints`() { + assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) + assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) + assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt index 1b76dffdc1..6ea7bf0903 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals class ContactImportFlowTest { private companion object { - const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + const val VALID_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index eb42e24f8a..5e8d6918b7 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -9,7 +9,9 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -56,8 +58,11 @@ class EditProfileViewModelTest : BaseUnitTest() { assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) } assertFalse(sut.uiState.value.showDeleteFailureDialog) - verify(pubkyRepo).deleteProfileWithSessionRetry() - verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) + inOrder(privatePaykitRepo, pubkyRepo).apply { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) + verify(pubkyRepo).deleteProfileWithSessionRetry() + verify(privatePaykitRepo).closeAndClear() + } } @Test @@ -74,8 +79,11 @@ class EditProfileViewModelTest : BaseUnitTest() { assertEquals(EditProfileEffect.DeleteSuccess, awaitItem()) } assertFalse(sut.uiState.value.showDeleteFailureDialog) - verify(pubkyRepo).deleteProfileWithSessionRetry() - verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) + inOrder(privatePaykitRepo, pubkyRepo).apply { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) + verify(pubkyRepo).deleteProfileWithSessionRetry() + verify(privatePaykitRepo).closeAndClear() + } } @Test @@ -114,8 +122,42 @@ class EditProfileViewModelTest : BaseUnitTest() { assertEquals(EditProfileEffect.DisconnectSuccess, awaitItem()) } assertFalse(sut.uiState.value.showDeleteFailureDialog) - verify(pubkyRepo).signOut() - verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) + inOrder(privatePaykitRepo, pubkyRepo).apply { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) + verify(pubkyRepo).signOut() + verify(privatePaykitRepo).closeAndClear() + } + } + + @Test + fun `disconnectProfile keeps profile connected when private cleanup fails`() = test { + val sut = createSut() + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(TestAppError("cleanup failed"))) + advanceUntilIdle() + + sut.disconnectProfile() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isSaving) + verify(pubkyRepo, never()).signOut() + verify(privatePaykitRepo, never()).closeAndClear() + } + + @Test + fun `deleteProfile should show retry dialog when private cleanup fails`() = test { + val sut = createSut() + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(TestAppError("cleanup failed"))) + advanceUntilIdle() + + sut.deleteProfile() + advanceUntilIdle() + + assertTrue(sut.uiState.value.showDeleteFailureDialog) + assertFalse(sut.uiState.value.isSaving) + verify(pubkyRepo, never()).deleteProfileWithSessionRetry() + verify(privatePaykitRepo, never()).closeAndClear() } @Test @@ -138,9 +180,9 @@ class EditProfileViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(createProfile())) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) - whenever { privatePaykitRepo.removePublishedEndpointsBestEffort(any()) } + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.closeAndClear(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) return EditProfileViewModel( context = context, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt index 17f1764566..1dead11590 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -29,7 +29,7 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PayContactsViewModelTest : BaseUnitTest() { companion object { - private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } private val context: Context = mock() diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index c1334adfa5..ab74e90519 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.repositories.PrivatePaykitRepo @@ -33,8 +35,25 @@ class ProfileViewModelTest : BaseUnitTest() { assertEquals(ProfileEffect.SignedOut, awaitItem()) } - verify(privatePaykitRepo).closeAndClear(markProfileRecoveryPending = true) - verify(pubkyRepo).signOut() + inOrder(privatePaykitRepo, pubkyRepo).apply { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) + verify(pubkyRepo).signOut() + verify(privatePaykitRepo).closeAndClear() + } + } + + @Test + fun `signOut keeps profile connected when private cleanup fails`() = test { + val sut = createSut() + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(RuntimeException("cleanup failed"))) + advanceUntilIdle() + + sut.signOut() + advanceUntilIdle() + + verify(pubkyRepo, never()).signOut() + verify(privatePaykitRepo, never()).closeAndClear() } private fun createSut(): ProfileViewModel { @@ -43,9 +62,9 @@ class ProfileViewModelTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) - whenever { privatePaykitRepo.removePublishedEndpointsBestEffort(any()) } + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.closeAndClear(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) return ProfileViewModel( context = context, diff --git a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt index 6e80be3a0b..51d772943a 100644 --- a/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModelTest.kt @@ -28,7 +28,7 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PaymentPreferenceViewModelTest : BaseUnitTest() { companion object { - private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } private val context: Context = mock() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 00f36eea11..7b2cff4ad2 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -57,7 +57,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { fun setUp() { whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.removePublishedEndpointsBestEffort(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) whenever { privatePaykitAddressReservationRepo.clear() }.thenReturn(Unit) onWipeCalled = false @@ -109,10 +109,10 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() - inOrder.verify(privatePaykitRepo).removePublishedEndpointsBestEffort(any()) + inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) + inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(privatePaykitRepo).closeAndClear() inOrder.verify(privatePaykitAddressReservationRepo).clear() - inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(pubkyRepo).wipeLocalState() inOrder.verify(keychain).wipe() inOrder.verify(coreService).wipeData() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8561752f91..9b27683a42 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1256,7 +1256,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() val contact = PubkyProfile( - publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo", + publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", name = "Bob", bio = "", imageUrl = null, diff --git a/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt index e451fd4b00..d7c444aac2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt @@ -8,8 +8,8 @@ import kotlin.test.assertNull class PubkyRouteResolverTest { companion object { - private const val VALID_PUBLIC_KEY = "pubkyybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" - private const val OTHER_VALID_PUBLIC_KEY = "pubkya345h769ybndrfg8ejkmcpqxot1uwiszybndrfg8ejkmcpqxot1u" + private const val VALID_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val OTHER_VALID_PUBLIC_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 350e4d9081..d9b72f1443 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -41,7 +41,7 @@ class SettingsViewModelTest : BaseUnitTest() { private val contacts = MutableStateFlow( listOf( PubkyProfile( - publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo", + publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", name = "Alice", bio = "", imageUrl = null, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40ca00380e..37b1a3fbed 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.1.73" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc21" } 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 fce3d67f82504b6d48e7ee04350b7f2c38d73b74 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 13:28:57 +0300 Subject: [PATCH 2/4] fix: preserve paykit cancellation --- .../bitkit/repositories/PrivatePaykitRepo.kt | 56 +++++--- .../to/bitkit/services/PaykitSdkService.kt | 3 + .../repositories/PrivatePaykitRepoTest.kt | 129 +++++++++++++++++- 3 files changed, 163 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e4c55d08a2..fa9cd756fc 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -28,6 +28,7 @@ import to.bitkit.App import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService @@ -258,9 +259,9 @@ class PrivatePaykitRepo @Inject constructor( suspend fun beginSavedContactPayment(publicKey: String): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val normalizedKey = knownSavedContact(publicKey) - ?: return@runCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val result = beginContactPayment(normalizedKey).getOrElse { if (it is CancellationException) throw it @@ -269,7 +270,7 @@ class PrivatePaykitRepo @Inject constructor( it, context = TAG, ) - return@runCatching PublicPaykitPaymentResult.NoEndpoint + return@runSuspendCatching publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() } result } @@ -407,7 +408,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun beginContactPayment(publicKey: String): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive if (canPublishPrivateEndpoints()) { publishLocalEndpoints( @@ -434,7 +435,9 @@ class PrivatePaykitRepo @Inject constructor( val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) if (privatePayable.isNotEmpty()) { - return@runCatching PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(privatePayable)) + return@runSuspendCatching PublicPaykitPaymentResult.Opened( + PublicPaykitRepo.paymentRequest(privatePayable), + ) } val publicEndpoints = resolution.payableEndpoints @@ -442,7 +445,9 @@ class PrivatePaykitRepo @Inject constructor( .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } val publicPayable = publicPaykitRepo.payableEndpoints(publicEndpoints) if (publicPayable.isNotEmpty()) { - return@runCatching PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(publicPayable)) + return@runSuspendCatching PublicPaykitPaymentResult.Opened( + PublicPaykitRepo.paymentRequest(publicPayable), + ) } if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { @@ -459,9 +464,9 @@ class PrivatePaykitRepo @Inject constructor( forceRefreshLightning: Boolean = false, requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() - if (keys.isEmpty()) return@runCatching + if (keys.isEmpty()) return@runSuspendCatching publicationMutex.withLock { if (!canPublishPrivateEndpoints()) { @@ -515,7 +520,7 @@ class PrivatePaykitRepo @Inject constructor( val updates = mutableListOf() for (publicKey in publicKeys) { - runCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { Logger.warn( "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", it, @@ -598,9 +603,9 @@ class PrivatePaykitRepo @Inject constructor( .distinct() private suspend fun drainPendingPrivateMessages(reason: String, advancingLinksFor: List = emptyList()) { - runCatching { + runSuspendCatching { advancingLinksFor.forEach { publicKey -> - runCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey) }.onFailure { Logger.warn( "Failed to advance private Paykit link for '${redacted(publicKey)}' during '$reason'", it, @@ -645,7 +650,7 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, forceRefreshLightning: Boolean = false, ): Result> = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val settings = settingsStore.data.first() val endpoints = mutableListOf() if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { @@ -682,15 +687,15 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, forceRefresh: Boolean = false, ): Result = withContext(serializedDispatcher) { - runCatching { - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + runSuspendCatching { + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload @@ -837,7 +842,7 @@ class PrivatePaykitRepo @Inject constructor( } } endpoint.methodId.isOnchain -> { - val isUsed = runCatching { coreService.isAddressUsed(endpoint.value) } + val isUsed = runSuspendCatching { coreService.isAddressUsed(endpoint.value) } .onFailure { Logger.warn( "Failed to check private Paykit endpoint usage for '${redacted(publicKey)}'", @@ -854,6 +859,7 @@ class PrivatePaykitRepo @Inject constructor( if (staleLightningHashes.isNotEmpty()) { discardRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { + if (it is CancellationException) throw it Logger.warn( "Failed to discard already-attempted private Paykit invoice for '${redacted(publicKey)}'", it, @@ -892,9 +898,9 @@ class PrivatePaykitRepo @Inject constructor( lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runCatching { - pubkyService.currentPublicKey() ?: return@runCatching false - val status = paykitSdkService.identityStatus() ?: return@runCatching false + private suspend fun hasLocalSecretKeyForCurrentProfile(): Boolean = runSuspendCatching { + pubkyService.currentPublicKey() ?: return@runSuspendCatching false + val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false status.privateLinkCapable }.getOrDefault(false) @@ -944,12 +950,15 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun paymentHashForBolt11(bolt11: String): String? = - runCatching { + runSuspendCatching { (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice?.paymentHash?.toHex() }.getOrNull() private suspend fun attemptedOutboundBolt11PaymentHashes(): Set = - lightningRepo.getPayments().getOrDefault(emptyList()) + lightningRepo.getPayments().getOrElse { + if (it is CancellationException) throw it + emptyList() + } .filter { it.direction == PaymentDirection.OUTBOUND && it.status != PaymentStatus.FAILED && @@ -962,7 +971,10 @@ class PrivatePaykitRepo @Inject constructor( paymentHash in receivedSettledPaymentHashes() private suspend fun receivedSettledPaymentHashes(): Set = - lightningRepo.getPayments().getOrDefault(emptyList()) + lightningRepo.getPayments().getOrElse { + if (it is CancellationException) throw it + emptyList() + } .filter { it.direction == PaymentDirection.INBOUND && it.status == PaymentStatus.SUCCEEDED && diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index a4d3c5be5d..3a01473e65 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -138,6 +138,7 @@ class PaykitSdkService @Inject constructor( secret: String, includeLocalSecret: Boolean = true, ): PubkySessionBootstrapResult { + isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = PubkySessionBootstrap().importSession( sessionSecret = secret, @@ -160,6 +161,7 @@ class PaykitSdkService @Inject constructor( homeserverPublicKey: String, signupCode: String?, ): PubkySessionBootstrapResult { + isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = PubkySessionBootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), @@ -178,6 +180,7 @@ class PaykitSdkService @Inject constructor( } suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { + isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = PubkySessionBootstrap().signIn(localSecretKey(secretKeyHex)) operationMutex.withLock { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index fc31117531..d402f0b621 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -39,6 +39,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -309,13 +310,77 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) }.thenThrow(CancellationException("cancelled")) - val result = sut.beginSavedContactPayment(CONTACT_KEY) + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment does not fall back to public when private refresh is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService, publicPaykitRepo) + whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } + .thenThrow(CancellationException("cancelled")) + + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment does not fall back to public when endpoint build is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService, publicPaykitRepo) + whenever { walletRepo.refreshReusableReceiveAddressIfReserved() } + .thenThrow(CancellationException("cancelled")) + + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment does not fall back to public when publish gate is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService, publicPaykitRepo) + whenever(pubkyService.currentPublicKey()).thenThrow(CancellationException("cancelled")) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is CancellationException) + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any()) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `beginSavedContactPayment falls back to public when unified resolution fails`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenThrow(IllegalStateException("private unavailable")) + whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( + Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), + ) + + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.Opened("public-fallback"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } + } + @Test fun `beginSavedContactPayment uses public endpoint from unified resolution when private has no endpoints`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) @@ -369,6 +434,64 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `beginSavedContactPayment does not fall back to public when private payable check is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = PRIVATE_ADDRESS, + ), + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + ), + ), + ) + whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) } + .thenThrow(CancellationException("cancelled")) + + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginSavedContactPayment does not fall back to public when private invoice decode is cancelled`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever { + paykitSdkService.prepareAndResolveContactPayment(CONTACT_KEY, includePublicEndpoints = true) + }.thenReturn( + resolution( + resolvedEndpoint( + methodId = MethodId.Bolt11, + value = PRIVATE_BOLT11, + ), + resolvedEndpoint( + methodId = MethodId.P2wpkh, + value = "bcrt1qpublic", + source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, + ), + ), + ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenThrow(CancellationException("cancelled")) + + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } + + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + @Test fun `backupSnapshot and restoreBackup use SDK backup state`() = test { val backup = "sdk-backup" From 8202a5977461bd54467ec4ae69f03cd4f01f01b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 14:00:16 +0300 Subject: [PATCH 3/4] fix: preserve paykit cancellation --- .../bitkit/repositories/PrivatePaykitRepo.kt | 80 ++++---- .../java/to/bitkit/repositories/PubkyRepo.kt | 175 ++++++++++-------- .../bitkit/repositories/PublicPaykitRepo.kt | 31 ++-- .../to/bitkit/services/PaykitSdkService.kt | 3 +- 4 files changed, 159 insertions(+), 130 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fa9cd756fc..15e3e926cd 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -104,11 +104,11 @@ class PrivatePaykitRepo @Inject constructor( publicKeys: Collection, requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) if (!canPublishPrivateEndpoints()) { if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable - return@runCatching + return@runSuspendCatching } addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() @@ -124,17 +124,17 @@ class PrivatePaykitRepo @Inject constructor( publicKeys: Collection, requireImmediatePublication: Boolean = false, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val wasCleanupPending = isContactSharingCleanupPending() if (wasCleanupPending && !canPublishPrivateEndpoints()) { if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable - return@runCatching + return@runSuspendCatching } updateContactSharingCleanupPending(false) prepareSavedContacts(publicKeys, requireImmediatePublication).onFailure { if (wasCleanupPending) { - runCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) + runSuspendCatching { updateContactSharingCleanupPending(true) }.onFailure(it::addSuppressed) } }.getOrThrow() } @@ -142,9 +142,9 @@ class PrivatePaykitRepo @Inject constructor( suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val keys = rememberSavedContacts(publicKeys, replacing = true) - if (!canPublishPrivateEndpoints()) return@runCatching + if (!canPublishPrivateEndpoints()) return@runSuspendCatching publishLocalEndpoints(keys, reason = "refresh").getOrThrow() } } @@ -153,8 +153,8 @@ class PrivatePaykitRepo @Inject constructor( reason: String, forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { - runCatching { - if (!canPublishPrivateEndpoints()) return@runCatching + runSuspendCatching { + if (!canPublishPrivateEndpoints()) return@runSuspendCatching publishLocalEndpoints( publicKeys = knownSavedContactKeys.toList(), reason = reason, @@ -168,7 +168,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun retryPendingEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { if (isContactSharingCleanupPending()) { removePublishedEndpoints().getOrThrow() clearUnsavedContactState(savedPublicKeys).getOrThrow() @@ -183,7 +183,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun pruneUnsavedContactState( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val savedKeys = rememberSavedContacts(savedPublicKeys, replacing = true).toSet() val staleKeys = ensureState().contacts.keys.filter { it !in savedKeys } staleKeys.forEach { removeSavedContact(it).getOrThrow() } @@ -192,8 +192,8 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun removeSavedContact(publicKey: String): Result = withContext(serializedDispatcher) { - runCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runCatching + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching knownSavedContactKeys.remove(normalizedKey) removePublishedEndpoints(normalizedKey).onFailure { updateDeletedContactCleanupPending(normalizedKey, true) @@ -211,7 +211,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val removalError = removePublishedEndpoints().exceptionOrNull() if (removalError != null) { updateContactSharingCleanupPending(true) @@ -230,7 +230,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun setContactSharingCleanupPending(isPending: Boolean): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { updateContactSharingCleanupPending(isPending) } } @@ -243,7 +243,7 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { publicationMutex.withLock { pendingMessageDrainRetryJob?.cancel() pendingMessageDrainRetryJob = null @@ -280,15 +280,15 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, paymentHashes: Set, ): Result = withContext(serializedDispatcher) { - runCatching { - if (paymentHashes.isEmpty()) return@runCatching - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runCatching - val contactState = ensureState().contacts[normalizedKey] ?: return@runCatching + runSuspendCatching { + if (paymentHashes.isEmpty()) return@runSuspendCatching + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching + val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val normalizedHashes = paymentHashes.map { it.lowercase() }.toSet() val filteredEntries = contactState.remoteEndpoints.filterNot { shouldDiscardRemoteLightningEntry(it, normalizedHashes) } - if (filteredEntries.size == contactState.remoteEndpoints.size) return@runCatching + if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching contactState.remoteEndpoints = filteredEntries persistState(markWalletBackup = true) @@ -299,14 +299,14 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, addresses: Set, ): Result = withContext(serializedDispatcher) { - runCatching { - if (addresses.isEmpty()) return@runCatching - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runCatching - val contactState = ensureState().contacts[normalizedKey] ?: return@runCatching + runSuspendCatching { + if (addresses.isEmpty()) return@runSuspendCatching + val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching + val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val filteredEntries = contactState.remoteEndpoints.filterNot { shouldDiscardRemoteOnchainEntry(it, addresses) } - if (filteredEntries.size == contactState.remoteEndpoints.size) return@runCatching + if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching contactState.remoteEndpoints = filteredEntries persistState(markWalletBackup = true) @@ -314,16 +314,16 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun handleReceivedPayment(paymentHash: String): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val matchingContacts = ensureState().contacts .filter { (publicKey, contactState) -> publicKey in knownSavedContactKeys && contactState.localInvoice?.paymentHash == paymentHash } .keys - if (matchingContacts.isEmpty()) return@runCatching + if (matchingContacts.isEmpty()) return@runSuspendCatching matchingContacts.forEach { rememberReceivedInvoicePaymentHash(paymentHash, it) } - if (!canPublishPrivateEndpoints()) return@runCatching + if (!canPublishPrivateEndpoints()) return@runSuspendCatching publishLocalEndpoints( publicKeys = matchingContacts, @@ -336,7 +336,7 @@ class PrivatePaykitRepo @Inject constructor( } suspend fun reconcileReceivedPayments(): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { settledPrivateInvoicePaymentHashes().forEach { handleReceivedPayment(it).getOrThrow() } @@ -345,8 +345,8 @@ class PrivatePaykitRepo @Inject constructor( suspend fun handleOnchainActivity(receivedAddresses: Collection = emptyList()): Result = withContext(serializedDispatcher) { - runCatching { - if (!canPublishPrivateEndpoints()) return@runCatching + runSuspendCatching { + if (!canPublishPrivateEndpoints()) return@runSuspendCatching val publicKeys = if (receivedAddresses.isEmpty()) { addressReservationRepo.contactsWithUsedReservedAddresses() } else { @@ -354,7 +354,7 @@ class PrivatePaykitRepo @Inject constructor( addressReservationRepo.currentContactPublicKeyForReservedAddress(it) } }.filter { it in knownSavedContactKeys }.distinct() - if (publicKeys.isEmpty()) return@runCatching + if (publicKeys.isEmpty()) return@runSuspendCatching publicKeys.forEach { addressReservationRepo.rotateAddress(it).getOrThrow() @@ -383,15 +383,15 @@ class PrivatePaykitRepo @Inject constructor( suspend fun backupSnapshot(): Result = withContext(serializedDispatcher) { - runCatching { - pubkyService.currentPublicKey() ?: return@runCatching null + runSuspendCatching { + pubkyService.currentPublicKey() ?: return@runSuspendCatching null paykitSdkService.exportBackupState() } } suspend fun restoreBackup(backup: String?): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { pendingMessageDrainRetryJob?.cancel() pendingMessageDrainRetryJob = null state = PrivatePaykitState() @@ -769,7 +769,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) .distinct() val firstError = keys.mapNotNull { publicKey -> @@ -780,7 +780,7 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val report = paykitSdkService.clearPrivatePaymentList(counterparty = publicKey) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable @@ -800,7 +800,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() ensureState().contacts.keys.filter { it !in savedKeys }.forEach { clearContactState(it) @@ -928,7 +928,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun retryPendingDeletedContactEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { - runCatching { + runSuspendCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() pendingDeletedContactCleanupPublicKeys().forEach { publicKey -> if (publicKey in savedKeys) { diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 507570daea..0147adb3f9 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -37,6 +37,7 @@ import to.bitkit.data.hasPublicPaykitPublicationState import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData @@ -155,7 +156,7 @@ class PubkyRepo @Inject constructor( // region Initialization suspend fun initialize() = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { ensureServiceInitialized() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) @@ -163,7 +164,7 @@ class PubkyRepo @Inject constructor( initializeMutex.withLock { _sessionRestorationFailed.update { false } - val result = runCatching { + val result = runSuspendCatching { val savedSessionSecret = runCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }.getOrNull() @@ -213,7 +214,7 @@ class PubkyRepo @Inject constructor( storedSecretKeyHex: String?, ): InitResult = withContext(ioDispatcher) { if (!savedSessionSecret.isNullOrEmpty()) { - runCatching { + runSuspendCatching { val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() InitResult.Restored(publicKey) }.getOrElse { @@ -237,7 +238,7 @@ class PubkyRepo @Inject constructor( InitResult.NoSession } } else { - runCatching { + runSuspendCatching { pubkyService.signIn(storedSecretKeyHex) notifyBackupStateChanged() val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() @@ -262,53 +263,70 @@ class PubkyRepo @Inject constructor( val attemptId = UUID.randomUUID().toString() _activeAuthAttemptId.update { attemptId } _authState.update { PubkyAuthState.Authenticating } - return runCatching { - val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() } - PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId) - }.onFailure { + return try { + runSuspendCatching { + val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() } + PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId) + }.onFailure { + _activeAuthAttemptId.update { null } + restoreAuthStateAfterAuthFlow() + } + } catch (e: CancellationException) { _activeAuthAttemptId.update { null } restoreAuthStateAfterAuthFlow() + throw e } } suspend fun completeAuthentication(): Result { val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) - return runCatching { - withContext(ioDispatcher) { - pubkyService.completeAuth() - ensureAuthAttemptActive(attemptId) - val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { - "No active Pubky session" - } - ensureAuthAttemptActive(attemptId) + return try { + runSuspendCatching { + withContext(ioDispatcher) { + pubkyService.completeAuth() + ensureAuthAttemptActive(attemptId) + val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { + "No active Pubky session" + } + ensureAuthAttemptActive(attemptId) - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - notifyBackupStateChanged() + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + notifyBackupStateChanged() - pk - } - }.onFailure { + pk + } + }.onFailure { + if (_activeAuthAttemptId.value == attemptId) { + _activeAuthAttemptId.update { null } + } + restoreAuthStateAfterAuthFlow() + }.onSuccess { pk -> + if (_activeAuthAttemptId.value == attemptId) { + _activeAuthAttemptId.update { null } + } + _publicKey.update { pk } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Completed pubky auth for '$pk'", context = TAG) + loadProfile() + loadContacts() + }.map { } + } catch (e: CancellationException) { if (_activeAuthAttemptId.value == attemptId) { _activeAuthAttemptId.update { null } } restoreAuthStateAfterAuthFlow() - }.onSuccess { pk -> - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - _publicKey.update { pk } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Completed pubky auth for '$pk'", context = TAG) - loadProfile() - loadContacts() - }.map { } + throw e + } } suspend fun cancelAuthentication() { - runCatching { - withContext(ioDispatcher) { pubkyService.cancelAuth() } - }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } - endAuthAttempt() + try { + runSuspendCatching { + withContext(ioDispatcher) { pubkyService.cancelAuth() } + }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } + } finally { + endAuthAttempt() + } } fun cancelAuthenticationSync() { @@ -389,14 +407,14 @@ class PubkyRepo @Inject constructor( // region Payment endpoints suspend fun removeBitkitPaymentEndpoints(): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { pubkyService.removeBitkitPaymentEndpoints() Unit } } suspend fun currentPublicKey(): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { pubkyService.currentPublicKey()?.ensurePubkyPrefix() } } @@ -411,7 +429,7 @@ class PubkyRepo @Inject constructor( _isLoadingProfile.update { true } try { - runCatching { + runSuspendCatching { withContext(ioDispatcher) { resolveContactProfile(pk).getOrThrow() ?: throw AppError("Profile not found") @@ -432,7 +450,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun fetchRemoteProfile(publicKey: String): Result = runCatching { + suspend fun fetchRemoteProfile(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { resolveContactProfile(publicKey).getOrThrow() } @@ -442,7 +460,7 @@ class PubkyRepo @Inject constructor( // region Profile creation & editing - suspend fun deriveKeys(): Result> = runCatching { + suspend fun deriveKeys(): Result> = runSuspendCatching { withContext(ioDispatcher) { val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() val rawKey = pubkyService.publicKeyFromSecret(secretKeyHex) @@ -460,13 +478,13 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): Result = runCatching { + ): Result = runSuspendCatching { withContext(ioDispatcher) { val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() val homegate = fetchHomegateSignupCode() - runCatching { + runSuspendCatching { pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) }.getOrElse { Logger.warn("Retrying sign in after sign up failed", it, context = TAG) @@ -495,7 +513,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun uploadAvatar(imageBytes: ByteArray): Result = runCatching { + suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" @@ -511,7 +529,7 @@ class PubkyRepo @Inject constructor( links: List, tags: List, imageUrl: String?, - ): Result = runCatching { + ): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" @@ -542,13 +560,13 @@ class PubkyRepo @Inject constructor( deleteProfile() } - suspend fun deleteProfile(): Result = runCatching { + suspend fun deleteProfile(): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } deleteAllContacts() - runCatching { + runSuspendCatching { pubkyService.deletePaykitProfile() }.getOrElse { if (!it.isMissingPubkyData()) { @@ -561,14 +579,14 @@ class PubkyRepo @Inject constructor( } private suspend fun deleteAllContacts() { - val records = runCatching { + val records = runSuspendCatching { pubkyService.contactRecords() }.getOrElse { if (!it.isMissingPubkyData()) throw it emptyList() } records.forEach { record -> - runCatching { + runSuspendCatching { pubkyService.removeContact(record.publicKey) }.onFailure { Logger.warn("Failed to delete contact '${record.publicKey}'", it, context = TAG) @@ -630,7 +648,7 @@ class PubkyRepo @Inject constructor( _isLoadingContacts.update { true } try { - runCatching { + runSuspendCatching { withContext(ioDispatcher) { val records = pubkyService.contactRecords() val overrides = pubkyStore.data.first().contactProfileOverrides @@ -638,7 +656,7 @@ class PubkyRepo @Inject constructor( coroutineScope { records.map { record -> async { - runCatching { + runSuspendCatching { contactProfile(record.publicKey, record.label, record.profile, overrides) }.onFailure { Logger.warn("Failed to load contact '${record.publicKey}'", it, context = TAG) @@ -679,7 +697,10 @@ class PubkyRepo @Inject constructor( } } - suspend fun addContact(publicKey: String, existingProfile: PubkyProfile? = null): Result = runCatching { + suspend fun addContact( + publicKey: String, + existingProfile: PubkyProfile? = null, + ): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = requireAddableContactPublicKey( publicKey = publicKey, @@ -706,7 +727,7 @@ class PubkyRepo @Inject constructor( imageUrl: String?, links: List, tags: List, - ): Result = runCatching { + ): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() val updatedProfile = PubkyProfile( @@ -729,7 +750,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun removeContact(publicKey: String): Result = runCatching { + suspend fun removeContact(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() pubkyService.removeContact(prefixedKey) @@ -740,13 +761,13 @@ class PubkyRepo @Inject constructor( } } - suspend fun importContacts(publicKeys: List): Result = runCatching { + suspend fun importContacts(publicKeys: List): Result = runSuspendCatching { withContext(ioDispatcher) { val imported = coroutineScope { publicKeys.map { contactPk -> val prefixedKey = contactPk.ensurePubkyPrefix() async { - runCatching { + runSuspendCatching { val profile = resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) pubkyService.saveContact(prefixedKey, profile.name) @@ -767,7 +788,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun prepareImport(): Result = runCatching { + suspend fun prepareImport(): Result = runSuspendCatching { clearPendingImport() val pk = requireNotNull(_publicKey.value) { "Not authenticated" } withContext(ioDispatcher) { @@ -778,7 +799,7 @@ class PubkyRepo @Inject constructor( contactKeys.map { contactPk -> val prefixedKey = contactPk.ensurePubkyPrefix() async { - runCatching { + runSuspendCatching { resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) }.getOrElse { PubkyProfile.placeholder(prefixedKey) } } @@ -801,12 +822,12 @@ class PubkyRepo @Inject constructor( // region Auth approval - suspend fun hasSecretKey(): Boolean = runCatching { - val publicKey = _publicKey.value ?: return@runCatching false + suspend fun hasSecretKey(): Boolean = runSuspendCatching { + val publicKey = _publicKey.value ?: return@runSuspendCatching false managedSecretKeyFor(publicKey) != null }.getOrDefault(false) - suspend fun approveAuth(authUrl: String): Result = runCatching { + suspend fun approveAuth(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { "No secret key available — use Ring to manage authorizations" @@ -819,7 +840,7 @@ class PubkyRepo @Inject constructor( // region Backup state - suspend fun snapshotSessionBackupState(): Result = runCatching { + suspend fun snapshotSessionBackupState(): Result = runSuspendCatching { withContext(ioDispatcher) { val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) if (!secretKeyHex.isNullOrEmpty()) { @@ -838,13 +859,13 @@ class PubkyRepo @Inject constructor( } } - suspend fun snapshotContactProfileOverrides(): Result?> = runCatching { + suspend fun snapshotContactProfileOverrides(): Result?> = runSuspendCatching { withContext(ioDispatcher) { pubkyStore.data.first().contactProfileOverrides.takeUnless { it.isEmpty() } } } - suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runCatching { + suspend fun restoreSessionBackupState(backup: PubkySessionBackupV1?): Result = runSuspendCatching { withContext(ioDispatcher) { ensureServiceInitialized() @@ -880,7 +901,9 @@ class PubkyRepo @Inject constructor( } } - suspend fun restoreContactProfileOverrides(overrides: Map?): Result = runCatching { + suspend fun restoreContactProfileOverrides( + overrides: Map?, + ): Result = runSuspendCatching { withContext(ioDispatcher) { pubkyStore.update { it.copy(contactProfileOverrides = overrides ?: emptyMap()) @@ -889,7 +912,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshSessionIfPossible(): Result = runCatching { + suspend fun refreshSessionIfPossible(): Result = runSuspendCatching { withContext(ioDispatcher) { val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext false @@ -914,12 +937,15 @@ class PubkyRepo @Inject constructor( val endpointCleanupResult = removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - val result = runCatching { + val result = runSuspendCatching { withContext(ioDispatcher) { pubkyService.signOut() } - }.recoverCatching { - Logger.warn("Forcing local sign out after server sign out failed", it, context = TAG) - withContext(ioDispatcher) { pubkyService.forceSignOut() } - } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { + Logger.warn("Forcing local sign out after server sign out failed", it, context = TAG) + runSuspendCatching { withContext(ioDispatcher) { pubkyService.forceSignOut() } } + }, + ) clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPublicPaykitState) return result @@ -969,13 +995,13 @@ class PubkyRepo @Inject constructor( ) } - private suspend fun resolveContactProfile(publicKey: String): Result = runCatching { + private suspend fun resolveContactProfile(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() var lastError: Throwable? = null repeat(2) { attempt -> - val result = runCatching { + val result = runSuspendCatching { val profile = pubkyService.resolveContactProfile( publicKey = prefixedKey, allowPubkyProfileFallback = true, @@ -985,7 +1011,6 @@ class PubkyRepo @Inject constructor( } } result.exceptionOrNull()?.let { error -> - if (error is CancellationException) throw error lastError = error } @@ -1073,7 +1098,7 @@ class PubkyRepo @Inject constructor( private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { evictPubkyImages() - runCatching { pubkyStore.reset() } + runSuspendCatching { pubkyStore.reset() } _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } @@ -1090,7 +1115,7 @@ class PubkyRepo @Inject constructor( private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - runCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } + runSuspendCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() clearAuthenticatedState() diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index d10f6792e7..7eed93aff1 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -18,6 +18,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork @@ -156,19 +157,19 @@ class PublicPaykitRepo @Inject constructor( private val publishMutex = Mutex() suspend fun beginPayment(publicKey: String): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val endpoints = fetchPublicEndpoints(publicKey).getOrThrow() - if (endpoints.isEmpty()) return@runCatching PublicPaykitPaymentResult.NoEndpoint + if (endpoints.isEmpty()) return@runSuspendCatching PublicPaykitPaymentResult.NoEndpoint val payable = endpoints.filter { isPayable(it) } - if (payable.isEmpty()) return@runCatching PublicPaykitPaymentResult.NotOpened + if (payable.isEmpty()) return@runSuspendCatching PublicPaykitPaymentResult.NotOpened PublicPaykitPaymentResult.Opened(paymentRequest(payable)) } } suspend fun hasPayablePublicEndpoint(publicKey: String): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { fetchPublicEndpoints(publicKey).getOrThrow().any { isPayable(it) } } } @@ -178,11 +179,11 @@ class PublicPaykitRepo @Inject constructor( } suspend fun syncPublishedEndpoints(publish: Boolean): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { if (!publish) { removePublishedEndpoints() settingsStore.update { it.copy(publicPaykitCleanupPending = false) } - return@runCatching + return@runSuspendCatching } val desired = buildWalletEndpoints(refresh = true) @@ -195,7 +196,7 @@ class PublicPaykitRepo @Inject constructor( forceRefreshLightning: Boolean = false, requireEndpoint: Boolean = false, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val desired = buildWalletEndpoints( refresh = false, forceRefreshLightning = forceRefreshLightning, @@ -207,10 +208,10 @@ class PublicPaykitRepo @Inject constructor( } suspend fun refreshPublishedBolt11ForPayment(paymentHash: String): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val settings = settingsStore.data.first() - if (!settings.sharesPublicPaykitEndpoints) return@runCatching - if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runCatching + if (!settings.sharesPublicPaykitEndpoints) return@runSuspendCatching + if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runSuspendCatching clearPublicBolt11Metadata() val desired = buildWalletEndpoints(refresh = true) @@ -219,7 +220,7 @@ class PublicPaykitRepo @Inject constructor( } private suspend fun fetchPublicEndpoints(publicKey: String): Result> = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey paykitSdkService.resolvePublicContactPayment(counterparty = normalizedKey).payableEndpoints .mapNotNull { parseEndpoint(it.identifier, it.payload) } @@ -362,10 +363,11 @@ class PublicPaykitRepo @Inject constructor( return nowMillis >= refreshAtMillis } - private suspend fun isPayable(endpoint: Endpoint): Boolean = runCatching { + private suspend fun isPayable(endpoint: Endpoint): Boolean = runSuspendCatching { when (endpoint.methodId) { MethodId.Bolt11 -> { - val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning ?: return@runCatching false + val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning + ?: return@runSuspendCatching false !scan.invoice.isExpired && !NetworkValidationHelper.isNetworkMismatch(scan.invoice.networkType.toLdkNetwork(), Env.network) } @@ -375,7 +377,8 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2sh, MethodId.P2pkh, -> { - val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.OnChain ?: return@runCatching false + val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.OnChain + ?: return@runSuspendCatching false val address = validateBitcoinAddress(scan.invoice.address) !NetworkValidationHelper.isNetworkMismatch(address.network.toLdkNetwork(), Env.network) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 3a01473e65..112dd09e38 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -56,6 +56,7 @@ import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.ext.fromHex +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint @@ -491,7 +492,7 @@ class PaykitSdkService @Inject constructor( } private suspend fun currentSdkStatePublicKeyLocked(): String? { - return runCatching { handle().identityStatus()?.publicKey } + return runSuspendCatching { handle().identityStatus()?.publicKey } .getOrElse { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) resetRuntime() From 8c9127d37384e5d35233d24ab8897f2f8772c8fa Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 15:01:49 +0300 Subject: [PATCH 4/4] fix: harden paykit restore --- .../java/to/bitkit/repositories/BackupRepo.kt | 8 ++----- .../java/to/bitkit/repositories/PubkyRepo.kt | 1 + .../to/bitkit/repositories/BackupRepoTest.kt | 6 ++--- .../to/bitkit/repositories/PubkyRepoTest.kt | 22 +++++++++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 57b5681405..59ef64d751 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -620,12 +620,8 @@ class BackupRepo @Inject constructor( val addressReservationRepo = privatePaykitAddressReservationRepo.get() addressReservationRepo.restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType).getOrThrow() val privateRepo = privatePaykitRepo.get() - if (parsed.paykitSdkBackupState != null) { - privateRepo.restoreBackup(parsed.paykitSdkBackupState).getOrThrow() - } else { - privateRepo.restoreBackup(null).onFailure { - Logger.warn("Failed to clear missing Paykit SDK backup state", it, context = TAG) - } + privateRepo.restoreBackup(parsed.paykitSdkBackupState).onFailure { + Logger.warn("Failed to restore Paykit SDK backup state", it, context = TAG) } addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() Logger.debug("Restored ${parsed.transfers.size} transfers", 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 0147adb3f9..f44dc2101a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -880,6 +880,7 @@ class PubkyRepo @Inject constructor( PubkySessionBackupKind.LocalSeed -> { val secretKeyHex = deriveLocalSecretKeyFromWalletSeed() + keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) pubkyService.signIn(secretKeyHex) val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() _publicKey.update { publicKey } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 47051410bd..84bafb8348 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -240,15 +240,15 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `full restore should fail when backed up Paykit SDK state fails to restore`() = test { + fun `full restore should continue when backed up Paykit SDK state fails to restore`() = test { stubWalletBackup(paykitSdkBackupState = "sdk-state") whenever { privatePaykitRepo.restoreBackup("sdk-state") } .thenReturn(Result.failure(BackupRepoTestError("restore failed"))) val result = sut.performFullRestoreFromLatestBackup() - assertTrue(result.isFailure) - verify(settingsStore, never()).update(any()) + assertTrue(result.isSuccess) + verify(settingsStore).update(any()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4d6af2f785..726c1415a3 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -728,6 +728,28 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(keychain) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, "derived_secret") } + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + + @Test + fun `restoreSessionBackupState should keep local secret when local seed sign in fails`() = test { + val seed = byteArrayOf(1, 2, 3) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) + whenever(pubkyService.mnemonicToSeed("test mnemonic", null)).thenReturn(seed) + whenever(pubkyService.deriveSecretKey(seed)).thenReturn("derived_secret") + whenever(pubkyService.signIn("derived_secret")).thenThrow(RuntimeException("offline")) + + val result = sut.restoreSessionBackupState( + PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed), + ) + + assertTrue(result.isFailure) + verifyBlocking(keychain) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, "derived_secret") } + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) } @Test