From 1d0ef1b418d752440f6eb1ca45a2075073870c94 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 14 May 2026 12:49:40 -0500 Subject: [PATCH 1/5] feat: add private paykit payments --- .../main/java/to/bitkit/data/CacheStore.kt | 2 + .../to/bitkit/data/PrivatePaykitStores.kt | 146 ++ .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../java/to/bitkit/models/BackupPayloads.kt | 15 + .../java/to/bitkit/repositories/BackupRepo.kt | 87 +- .../to/bitkit/repositories/LightningRepo.kt | 36 +- .../PrivatePaykitAddressReservationRepo.kt | 384 +++ .../bitkit/repositories/PrivatePaykitRepo.kt | 2275 +++++++++++++++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 14 + .../bitkit/repositories/PublicPaykitRepo.kt | 5 + .../java/to/bitkit/repositories/WalletRepo.kt | 53 +- .../java/to/bitkit/services/CoreService.kt | 286 ++- .../to/bitkit/services/LightningService.kt | 74 +- .../java/to/bitkit/services/PubkyService.kt | 95 + app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + .../screens/contacts/ContactDetailScreen.kt | 6 +- .../contacts/ContactDetailViewModel.kt | 24 +- .../screens/profile/EditProfileViewModel.kt | 6 + .../screens/profile/PayContactsViewModel.kt | 85 +- .../ui/screens/profile/ProfileViewModel.kt | 4 + .../AddressTypePreferenceViewModel.kt | 20 + .../to/bitkit/usecases/WipeWalletUseCase.kt | 8 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 160 +- .../ActivityDetailViewModelTest.kt | 6 +- .../bitkit/repositories/CurrencyRepoTest.kt | 14 +- .../bitkit/repositories/LightningRepoTest.kt | 2 +- ...PrivatePaykitAddressReservationRepoTest.kt | 239 ++ .../repositories/PrivatePaykitRepoTest.kt | 674 +++++ .../to/bitkit/repositories/WalletRepoTest.kt | 80 +- .../bitkit/services/LightningServiceTest.kt | 59 + .../profile/EditProfileViewModelTest.kt | 6 + .../profile/PayContactsViewModelTest.kt | 176 ++ .../AddressTypePreferenceViewModelTest.kt | 5 + .../bitkit/usecases/WipeWalletUseCaseTest.kt | 17 + .../viewmodels/AppViewModelSendFlowTest.kt | 81 +- changelog.d/next/private-paykit.added.md | 1 + gradle/libs.versions.toml | 2 +- 37 files changed, 4984 insertions(+), 165 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/services/LightningServiceTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt create mode 100644 changelog.d/next/private-paykit.added.md diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 890008fb7d..477d4f3c94 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -129,6 +129,8 @@ data class AppCacheData( val deletedActivities: List = listOf(), val pendingBoostActivities: List = listOf(), val backgroundReceive: NewTransactionSheetDetails? = null, + val addressSearchLastUsedReceiveIndexes: Map = mapOf(), + val addressSearchLastUsedChangeIndexes: Map = mapOf(), ) { fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "") } diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt new file mode 100644 index 0000000000..c5717f394b --- /dev/null +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -0,0 +1,146 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import to.bitkit.di.json +import to.bitkit.utils.Logger +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.privatePaykitCacheDataStore: DataStore by dataStore( + fileName = "private_paykit_cache.json", + serializer = PrivatePaykitCacheSerializer, +) + +private val Context.privatePaykitReservationDataStore: DataStore by dataStore( + fileName = "private_paykit_reservations.json", + serializer = PrivatePaykitReservationSerializer, +) + +@Singleton +class PrivatePaykitCacheStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val store = context.privatePaykitCacheDataStore + + val data: Flow = store.data + + suspend fun update(transform: (PrivatePaykitCacheData) -> PrivatePaykitCacheData) { + store.updateData(transform) + } + + suspend fun reset() { + store.updateData { PrivatePaykitCacheData() } + } +} + +@Singleton +class PrivatePaykitReservationStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val store = context.privatePaykitReservationDataStore + + val data: Flow = store.data + + suspend fun update(transform: (PrivatePaykitReservationData) -> PrivatePaykitReservationData) { + store.updateData(transform) + } + + suspend fun reset() { + store.updateData { PrivatePaykitReservationData() } + } +} + +@Serializable +data class PrivatePaykitCacheData( + val contacts: Map = emptyMap(), + val cleanupPending: Boolean = false, + val deletedContactCleanupPendingPublicKeys: Set = emptySet(), +) + +@Serializable +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 linkFailureCount: Int = 0, +) + +@Serializable +data class PrivatePaykitStoredPaymentEntryData( + val methodId: String, + val endpointData: String, +) + +@Serializable +data class PrivatePaykitStoredInvoiceData( + val bolt11: String, + val paymentHash: String, + val expiresAt: Long, +) + +@Serializable +data class PrivatePaykitReservationData( + val version: Int = 1, + val reservedReceiveIndexesByAddressType: Map> = emptyMap(), + val contactAssignments: Map = emptyMap(), + val contactAssignmentHistory: Map> = emptyMap(), + val restoredReservedReceiveIndexCeilingsByAddressType: Map = emptyMap(), +) + +@Serializable +data class PrivatePaykitStoredAssignmentData( + val addressType: String, + val receiveIndex: Int, + val address: String = "", +) + +private object PrivatePaykitCacheSerializer : Serializer { + private const val TAG = "PrivatePaykitCacheSerializer" + + override val defaultValue: PrivatePaykitCacheData = PrivatePaykitCacheData() + + override suspend fun readFrom(input: InputStream): PrivatePaykitCacheData = + runCatching { + json.decodeFromString(input.readBytes().decodeToString()) + }.getOrElse { + Logger.error("Failed to deserialize PrivatePaykitCacheData", it, context = TAG) + defaultValue + } + + override suspend fun writeTo(t: PrivatePaykitCacheData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} + +private object PrivatePaykitReservationSerializer : Serializer { + private const val TAG = "PrivatePaykitReservationSerializer" + + override val defaultValue: PrivatePaykitReservationData = PrivatePaykitReservationData() + + override suspend fun readFrom(input: InputStream): PrivatePaykitReservationData = + runCatching { + json.decodeFromString(input.readBytes().decodeToString()) + }.getOrElse { + Logger.error("Failed to deserialize PrivatePaykitReservationData", it, context = TAG) + defaultValue + } + + override suspend fun writeTo(t: PrivatePaykitReservationData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} 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 1c6776e68b..5c2e0ceedb 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -174,6 +174,7 @@ class Keychain @Inject constructor( PIN, PIN_ATTEMPTS_REMAINING, PAYKIT_SESSION, + PRIVATE_PAYKIT_SECRET_STATE, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 3b6a0b4eca..27bea5d2fd 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -19,6 +19,21 @@ data class WalletBackupV1( val version: Int = 1, 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, ) @Serializable diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b99a478473..2629275559 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -52,6 +52,7 @@ import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -85,6 +86,8 @@ class BackupRepo @Inject constructor( private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: Provider, + private val privatePaykitAddressReservationRepo: Provider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, private val clock: Clock, @@ -279,6 +282,26 @@ class BackupRepo @Inject constructor( } 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) + // BLOCKTANK - Observe blocktank state changes (orders, cjitEntries, info) val blocktankJob = scope.launch { blocktankRepo.blocktankState @@ -438,7 +461,7 @@ class BackupRepo @Inject constructor( cacheStore.updateBackupStatus(category) { it.copy(running = false) } - Logger.error("Backup failed for: '$category'", e = e, context = TAG) + Logger.error("Backup failed for: '$category'", e, context = TAG) } } @@ -461,16 +484,7 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } - BackupCategory.WALLET -> { - val transfers = db.transferDao().getAll() - - val payload = WalletBackupV1( - createdAt = currentTimeMillis(), - transfers = transfers - ) - - json.encodeToString(payload).toByteArray() - } + BackupCategory.WALLET -> getWalletBackupDataBytes() BackupCategory.METADATA -> getMetadataBackupDataBytes() @@ -520,6 +534,29 @@ class BackupRepo @Inject constructor( json.encodeToString(payload).toByteArray() } + private suspend fun getWalletBackupDataBytes(): ByteArray { + val transfers = db.transferDao().getAll() + val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot() + .onFailure { + Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG) + } + .getOrDefault(null) + val privateLinks = privatePaykitRepo.get().backupSnapshot() + .onFailure { + Logger.warn("Failed to snapshot private Paykit contact links", it, context = TAG) + } + .getOrDefault(null) + + val payload = WalletBackupV1( + createdAt = currentTimeMillis(), + transfers = transfers, + privatePaykitHighestReservedReceiveIndexByAddressType = privateReservations, + privatePaykitContactLinks = privateLinks, + ) + + return json.encodeToString(payload).toByteArray() + } + suspend fun performFullRestoreFromLatestBackup( onCacheRestored: suspend () -> Unit = {}, ): Result = withContext(ioDispatcher) { @@ -553,10 +590,7 @@ class BackupRepo @Inject constructor( parsed.createdAt } performRestore(BackupCategory.WALLET) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - db.transferDao().upsert(parsed.transfers) - Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) - parsed.createdAt + restoreWalletBackup(dataBytes) } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) @@ -573,7 +607,7 @@ class BackupRepo @Inject constructor( }.onSuccess { settingsStore.update { it.copy(backupVerified = true) } }.onFailure { e -> - Logger.warn("Full restore error", e = e, context = TAG) + Logger.warn("Full restore error", e, context = TAG) } _isRestoring.update { false } @@ -581,6 +615,27 @@ class BackupRepo @Inject constructor( return@withContext result } + private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { + val parsed = json.decodeFromString(String(dataBytes)) + db.transferDao().upsert(parsed.transfers) + privatePaykitAddressReservationRepo.get() + .restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType) + .onFailure { + Logger.warn("Failed to restore private Paykit reservations", it, context = TAG) + } + privatePaykitRepo.get().restoreBackup(parsed.privatePaykitContactLinks) + .onFailure { + Logger.warn("Failed to restore private Paykit contact links", it, context = TAG) + } + privatePaykitAddressReservationRepo.get() + .reconcileReservedIndexesWithLdk() + .onFailure { + Logger.warn("Failed to reconcile restored private Paykit reservations", it, context = TAG) + } + Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) + return parsed.createdAt + } + suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { runCatching { withTimeout(VSS_TIMESTAMP_TIMEOUT) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2eb6a1ebd1..cc35bc5f3c 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -76,6 +76,7 @@ import to.bitkit.models.toAddressType import to.bitkit.models.toCoinSelectAlgorithm import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.LnurlChannelResponse @@ -925,6 +926,36 @@ class LightningRepo @Inject constructor( runCatching { lightningService.newAddress() } } + suspend fun newAddressForType(addressType: AddressType): Result = + executeWhenNodeRunning("newAddressForType") { + runCatching { lightningService.newAddressForType(addressType) } + } + + suspend fun newAddressInfoForType(addressType: AddressType): Result = + executeWhenNodeRunning("newAddressInfoForType") { + runCatching { lightningService.newAddressInfoForType(addressType) } + } + + suspend fun addressInfoForType(addressType: AddressType, receiveIndex: Int): Result = + executeWhenNodeRunning("addressInfoForType") { + runCatching { lightningService.addressInfoForType(addressType, receiveIndex) } + } + + suspend fun addressInfosForType( + addressType: AddressType, + isChange: Boolean, + startIndex: Int, + count: Int, + ): Result> = + executeWhenNodeRunning("addressInfosForType") { + runCatching { lightningService.addressInfosForType(addressType, isChange, startIndex, count) } + } + + suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType): Result = + executeWhenNodeRunning("revealReceiveAddresses") { + runCatching { lightningService.revealReceiveAddresses(toReceiveIndex, forType) } + } + suspend fun createInvoice( amountSats: ULong? = null, description: String, @@ -1185,7 +1216,8 @@ class LightningRepo @Inject constructor( } suspend fun getPayments(): Result> = executeWhenNodeRunning("getPayments") { - val payments = lightningService.payments ?: return@executeWhenNodeRunning Result.failure(GetPaymentsError()) + val payments = lightningService.listPayments() + ?: return@executeWhenNodeRunning Result.failure(GetPaymentsError()) Result.success(payments) } @@ -1221,7 +1253,7 @@ class LightningRepo @Inject constructor( }.recoverCatching { if (it is CancellationException) throw it val fallbackFee = 1000uL - Logger.warn("calculateTotalFee error, using fallback of '$fallbackFee'", e = it, context = TAG) + Logger.warn("calculateTotalFee error, using fallback of '$fallbackFee'", it, context = TAG) return@recoverCatching fallbackFee } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt new file mode 100644 index 0000000000..9a013e8e71 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -0,0 +1,384 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AddressType +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import to.bitkit.data.PrivatePaykitReservationData +import to.bitkit.data.PrivatePaykitReservationStore +import to.bitkit.data.PrivatePaykitStoredAssignmentData +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.models.DEFAULT_ADDRESS_TYPE +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.addressTypeFromAddress +import to.bitkit.models.toAddressType +import to.bitkit.models.toSettingsString +import to.bitkit.services.CoreService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +sealed class PrivatePaykitAddressReservationError(message: String) : AppError(message) { + data object AddressReservationFailed : PrivatePaykitAddressReservationError( + "Unable to reserve private Paykit address", + ) +} + +@Singleton +@Suppress("TooManyFunctions") +class PrivatePaykitAddressReservationRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val reservationStore: PrivatePaykitReservationStore, + private val settingsStore: SettingsStore, + private val coreService: CoreService, + private val lightningRepo: LightningRepo, +) { + companion object { + private const val TAG = "PrivatePaykitAddressReservationRepo" + } + + private val mutex = Mutex() + private var ledger: PrivatePaykitReservationData? = null + + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + + suspend fun backupSnapshot(): Result?> = withContext(ioDispatcher) { + runCatching { + val snapshot = locked { highestReservedReceiveIndexByAddressType(it) } + snapshot.takeIf { it.isNotEmpty() } + } + } + + suspend fun restoreBackup(highestReservedReceiveIndexByAddressType: Map?): Result = + withContext(ioDispatcher) { + runCatching { + locked { + val restored = highestReservedReceiveIndexByAddressType + ?.filterValues { it >= 0 } + .orEmpty() + val next = PrivatePaykitReservationData( + reservedReceiveIndexesByAddressType = emptyMap(), + contactAssignments = emptyMap(), + contactAssignmentHistory = emptyMap(), + restoredReservedReceiveIndexCeilingsByAddressType = restored, + ) + ledger = next + persist(next) + } + notifyBackupStateChanged() + }.onFailure { + Logger.error("Failed to restore private Paykit reservations", it, context = TAG) + } + } + + suspend fun currentOrRotatedAddress(publicKey: String): Result = withContext(ioDispatcher) { + runCatching { + val normalizedKey = normalizedPublicKey(publicKey) + val current = locked { it.contactAssignments[normalizedKey] } + if (current != null && isAddressTypeMonitored(current.addressType)) { + val address = resolvedAddress(current).getOrThrow() + if (!isReservedAddressUsed(address)) return@runCatching address + } else if (current != null) { + clearCurrentAssignment(normalizedKey) + } + + allocateAddress(normalizedKey).getOrThrow() + }.onFailure { + Logger.warn( + "Failed to get private Paykit address for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + } + + suspend fun rotateAddress(publicKey: String): Result = withContext(ioDispatcher) { + runCatching { + allocateAddress(normalizedPublicKey(publicKey)).getOrThrow() + }.onFailure { + Logger.warn( + "Failed to rotate private Paykit address for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + } + + suspend fun nextReusableReceiveAddress(): Result = + nextReusableReceiveAddress(selectedAddressType()) + + suspend fun nextReusableReceiveAddress(addressType: AddressType): Result = + withContext(ioDispatcher) { + runCatching { + prepareReusableReceive(addressType).getOrThrow() + val addressInfo = lightningRepo.newAddressInfoForType(addressType).getOrThrow() + if (isUnavailableForReusableReceive(addressInfo.index, addressType.toSettingsString())) { + throw PrivatePaykitAddressReservationError.AddressReservationFailed + } + addressInfo.address + }.onFailure { + Logger.error("Failed to create non-reserved receive address", it, context = TAG) + } + } + + suspend fun reconcileReservedIndexesWithLdk(): Result = withContext(ioDispatcher) { + runCatching { + locked { highestReservedReceiveIndexByAddressType(it) }.forEach { (addressTypeKey, highestReserved) -> + val addressType = addressTypeKey.toAddressType() ?: return@forEach + if (!isAddressTypeMonitored(addressTypeKey)) return@forEach + reconcileAddressTypeWithLdk(addressType, highestReserved).getOrThrow() + } + }.onFailure { + Logger.warn("Failed to reconcile private Paykit address reservations", it, context = TAG) + } + } + + suspend fun isUnavailableForReusableReceive(address: String): Boolean = withContext(ioDispatcher) { + if (address.isBlank()) return@withContext false + val addressType = address.addressTypeFromAddress()?.toAddressType() ?: return@withContext false + isUnavailableForReusableReceive(address, addressType) + } + + suspend fun contactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { + if (address.isBlank()) return@withContext null + val addressType = address.addressTypeFromAddress() ?: return@withContext null + + val assignments = locked { ledger -> + val current = ledger.contactAssignments.map { it.key to it.value } + val history = ledger.contactAssignmentHistory.flatMap { (publicKey, assignments) -> + assignments.map { publicKey to it } + } + (current + history).distinctBy { (_, assignment) -> assignment.assignmentKey() } + } + + assignments.firstOrNull { (_, assignment) -> + assignment.addressType == addressType && + (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) + }?.first + } + + suspend fun currentContactPublicKeyForReservedAddress(address: String): String? = withContext(ioDispatcher) { + if (address.isBlank()) return@withContext null + val addressType = address.addressTypeFromAddress() ?: return@withContext null + val assignments = locked { it.contactAssignments.entries.map { entry -> entry.key to entry.value } } + assignments.firstOrNull { (_, assignment) -> + assignment.addressType == addressType && + (assignment.address == address || resolvedAddress(assignment).getOrNull() == address) + }?.first + } + + suspend fun contactsWithUsedReservedAddresses(): List = withContext(ioDispatcher) { + val assignments = locked { it.contactAssignments.map { entry -> entry.key to entry.value } } + assignments.mapNotNull { (publicKey, assignment) -> + val address = resolvedAddress(assignment).getOrNull() ?: return@mapNotNull null + val isUsed = runCatching { isReservedAddressUsed(address) } + .onFailure { + Logger.warn( + "Failed to check private Paykit address usage for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + .getOrDefault(false) + publicKey.takeIf { isUsed } + } + } + + private suspend fun isReservedAddressUsed(address: String): Boolean { + if (coreService.isAddressUsed(address)) return true + if (!lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) return false + return lightningRepo.getAddressBalance(address).getOrDefault(0u) > 0u + } + + suspend fun hasContactAssignment(publicKey: String): Boolean = withContext(ioDispatcher) { + val normalizedKey = normalizedPublicKey(publicKey) + locked { it.contactAssignments.containsKey(normalizedKey) } + } + + suspend fun clearContactAssignment(publicKey: String) = withContext(ioDispatcher) { + val normalizedKey = normalizedPublicKey(publicKey) + locked { current -> + val hadAssignment = normalizedKey in current.contactAssignments + val hadHistory = normalizedKey in current.contactAssignmentHistory + if (!hadAssignment && !hadHistory) return@locked + val next = current.copy( + contactAssignments = current.contactAssignments - normalizedKey, + contactAssignmentHistory = current.contactAssignmentHistory - normalizedKey, + ) + ledger = next + persist(next) + notifyBackupStateChanged() + } + } + + suspend fun clearContactAssignments(excludingPublicKeys: Collection) = withContext(ioDispatcher) { + val savedKeys = excludingPublicKeys.mapNotNull { normalizedPublicKeyOrNull(it) }.toSet() + locked { current -> + val next = current.copy( + contactAssignments = current.contactAssignments.filterKeys { it in savedKeys }, + contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it in savedKeys }, + ) + if (next == current) return@locked + ledger = next + persist(next) + notifyBackupStateChanged() + } + } + + suspend fun clear() = withContext(ioDispatcher) { + locked { + ledger = PrivatePaykitReservationData() + reservationStore.reset() + notifyBackupStateChanged() + } + } + + private suspend fun allocateAddress(publicKey: String): Result = withContext(ioDispatcher) { + runCatching { + val addressType = selectedAddressType() + val addressTypeKey = addressType.toSettingsString() + + prepareReusableReceive(addressType).getOrThrow() + val addressInfo = lightningRepo.newAddressInfoForType(addressType).getOrThrow() + if (isUnavailableForReusableReceive(addressInfo.index, addressTypeKey)) { + throw PrivatePaykitAddressReservationError.AddressReservationFailed + } + val assignment = PrivatePaykitStoredAssignmentData( + addressType = addressTypeKey, + receiveIndex = addressInfo.index, + address = addressInfo.address, + ) + locked { current -> + val reserved = current.reservedReceiveIndexesByAddressType[addressTypeKey].orEmpty() + addressInfo.index + val history = current.contactAssignmentHistory[publicKey].orEmpty() + .let { if (assignment in it) it else it + assignment } + val next = current.copy( + reservedReceiveIndexesByAddressType = current.reservedReceiveIndexesByAddressType + + (addressTypeKey to reserved), + contactAssignments = current.contactAssignments + (publicKey to assignment), + contactAssignmentHistory = current.contactAssignmentHistory + (publicKey to history), + ) + ledger = next + persist(next) + } + notifyBackupStateChanged() + addressInfo.address + } + } + + private suspend fun selectedAddressType(): AddressType { + val settings = settingsStore.data.first() + return settings.selectedAddressType.toAddressType() ?: DEFAULT_ADDRESS_TYPE + } + + private suspend fun isAddressTypeMonitored(addressType: String): Boolean { + val settings = settingsStore.data.first() + return addressType == settings.selectedAddressType || addressType in settings.addressTypesToMonitor + } + + private suspend fun isUnavailableForReusableReceive(receiveIndex: Int, addressType: String): Boolean { + val current = locked { it } + if (receiveIndex in current.reservedReceiveIndexesByAddressType[addressType].orEmpty()) return true + return receiveIndex <= (current.restoredReservedReceiveIndexCeilingsByAddressType[addressType] ?: -1) + } + + private suspend fun prepareReusableReceive(addressType: AddressType): Result = withContext(ioDispatcher) { + runCatching { + val addressTypeKey = addressType.toSettingsString() + val highestReserved = locked { highestReservedReceiveIndexByAddressType(it)[addressTypeKey] } + ?: return@runCatching + reconcileAddressTypeWithLdk(addressType, highestReserved).getOrThrow() + } + } + + private suspend fun reconcileAddressTypeWithLdk(addressType: AddressType, highestReserved: Int): Result = + lightningRepo.revealReceiveAddresses(toReceiveIndex = highestReserved, forType = addressType) + + private suspend fun isUnavailableForReusableReceive(address: String, addressType: AddressType): Boolean { + val addressTypeKey = addressType.toSettingsString() + val reservedIndexes = locked { it.reservedReceiveIndexesByAddressType[addressTypeKey].orEmpty() } + return reservedIndexes.any { receiveIndex -> + val reservedAddress = resolvedAddress( + PrivatePaykitStoredAssignmentData( + addressType = addressTypeKey, + receiveIndex = receiveIndex, + ), + ).getOrNull() + reservedAddress == address + } + } + + private suspend fun clearCurrentAssignment(publicKey: String) { + locked { current -> + val next = current.copy(contactAssignments = current.contactAssignments - publicKey) + ledger = next + persist(next) + notifyBackupStateChanged() + } + } + + private suspend fun resolvedAddress(assignment: PrivatePaykitStoredAssignmentData): Result = + withContext(ioDispatcher) { + runCatching { + assignment.address.takeIf { it.isNotBlank() } ?: lightningRepo.addressInfoForType( + addressType = assignment.addressType.toAddressType() + ?: throw PrivatePaykitAddressReservationError.AddressReservationFailed, + receiveIndex = assignment.receiveIndex, + ).getOrThrow().address + } + } + + private suspend fun ensureLedger(): PrivatePaykitReservationData { + ledger?.let { return it } + return reservationStore.data.first().also { ledger = it } + } + + private suspend fun locked(block: suspend (PrivatePaykitReservationData) -> T): T { + return mutex.withLock { block(ensureLedger()) } + } + + private suspend fun persist(data: PrivatePaykitReservationData) { + reservationStore.update { data } + } + + private fun highestReservedReceiveIndexByAddressType( + ledger: PrivatePaykitReservationData, + ): Map { + val reserved = ledger.reservedReceiveIndexesByAddressType + .mapValues { (_, indexes) -> indexes.maxOrNull() ?: -1 } + .filterValues { it >= 0 } + return (reserved.keys + ledger.restoredReservedReceiveIndexCeilingsByAddressType.keys) + .associateWith { + maxOf( + reserved[it] ?: -1, + ledger.restoredReservedReceiveIndexCeilingsByAddressType[it] ?: -1, + ) + } + .filterValues { it >= 0 } + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } + + private fun normalizedPublicKey(publicKey: String): String = + normalizedPublicKeyOrNull(publicKey) + ?: throw PrivatePaykitAddressReservationError.AddressReservationFailed + + private fun normalizedPublicKeyOrNull(publicKey: String): String? = + PubkyPublicKeyFormat.normalized(publicKey) + + private fun redacted(publicKey: String): String = + PubkyPublicKeyFormat.redacted(publicKey) + + private fun PrivatePaykitStoredAssignmentData.assignmentKey(): String = "$addressType:$receiveIndex" +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt new file mode 100644 index 0000000000..ca861593c9 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -0,0 +1,2275 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.PaykitFfiException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.App +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.PrivatePaykitContactCacheData +import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.data.PrivatePaykitStoredPaymentEntryData +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.di.json +import to.bitkit.ext.toHex +import to.bitkit.models.PrivatePaykitContactLinkBackupV1 +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.CoreService +import to.bitkit.services.PubkyService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong +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 + +sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) { + data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") + data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") + data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed") + class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause) +} + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Singleton +@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") +class PrivatePaykitRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val pubkyService: PubkyService, + private val keychain: Keychain, + private val cacheStore: PrivatePaykitCacheStore, + private val settingsStore: SettingsStore, + private val addressReservationRepo: PrivatePaykitAddressReservationRepo, + private val lightningRepo: LightningRepo, + private val walletRepo: WalletRepo, + private val publicPaykitRepo: PublicPaykitRepo, + private val coreService: CoreService, + private val clock: Clock, +) { + companion object { + private const val TAG = "PrivatePaykitRepo" + private const val MAX_NOISE_PAYLOAD_BYTES = 1000 + 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 PRIVATE_ENDPOINT_REMOVAL_PAYLOAD = """{"value":""}""" + 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 COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS = 5 * 60L + private const val FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS = 8L + 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 + private const val PENDING_PUBLICATION_RETRY_ATTEMPTS = 60 + private val noisePayloadJson = Json(json) { + prettyPrint = false + } + private val privateInvoiceExpiry = 24.hours + private val invoiceRefreshBuffer = 30.minutes + private val pendingPublicationRetryDelay = 5.seconds + + fun shouldInitiate(ownPublicKey: String, remotePublicKey: String): Boolean { + val own = PubkyPublicKeyFormat.normalized(ownPublicKey) ?: ownPublicKey + val remote = PubkyPublicKeyFormat.normalized(remotePublicKey) ?: remotePublicKey + return own > remote + } + + fun isDuplicatePaymentError(error: Throwable): Boolean { + val errors = generateSequence(error) { it.cause }.toList() + if (errors.any { it is NodeException.DuplicatePayment }) return true + + val reason = errors.mapNotNull { it.message } + .joinToString(separator = " ") + .lowercase() + return "duplicate payment" in reason || "duplicatepayment" in reason + } + } + + private var state: PrivatePaykitState? = null + 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 _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + + suspend fun reconcileReservedReceiveIndexes(): Result = + addressReservationRepo.reconcileReservedIndexesWithLdk() + + suspend fun prepareSavedContacts(publicKeys: Collection): Result = withContext(serializedDispatcher) { + runCatching { + val keys = rememberSavedContacts(publicKeys, replacing = true) + if (!canPublishPrivateEndpoints()) return@runCatching + addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() + publishLocalEndpoints(keys, maxAdvanceSteps = 3, reason = "prepare").getOrThrow() + } + } + + suspend fun refreshSavedContactEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runCatching { + val keys = rememberSavedContacts(publicKeys, replacing = true) + if (!canPublishPrivateEndpoints()) return@runCatching + publishLocalEndpoints(keys, maxAdvanceSteps = 1, reason = "refresh").getOrThrow() + } + } + + suspend fun refreshKnownSavedContactEndpoints(reason: String): Result = withContext(serializedDispatcher) { + runCatching { + if (!canPublishPrivateEndpoints()) return@runCatching + publishLocalEndpoints(knownSavedContactKeys.toList(), maxAdvanceSteps = 1, reason = reason).getOrThrow() + } + } + + suspend fun retryPendingEndpointRemoval( + savedPublicKeys: Collection, + ): Result = withContext(serializedDispatcher) { + runCatching { + if (isContactSharingCleanupPending()) { + publicPaykitRepo.syncPublishedEndpoints(publish = false).getOrThrow() + removePublishedEndpoints().getOrThrow() + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) + } + retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() + }.onFailure { + Logger.warn("Failed to retry pending Paykit contact endpoint removal", it, context = TAG) + } + } + + suspend fun pruneUnsavedContactState( + savedPublicKeys: Collection, + ): Result = withContext(serializedDispatcher) { + runCatching { + val savedKeys = rememberSavedContacts(savedPublicKeys, replacing = true).toSet() + val staleKeys = ensureState().contacts.keys.filter { it !in savedKeys } + staleKeys.forEach { removeSavedContact(it).getOrThrow() } + addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) + } + } + + suspend fun removeSavedContact(publicKey: String): Result = withContext(serializedDispatcher) { + 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)}'", + it, + context = TAG, + ) + }.getOrThrow() + clearContactState(normalizedKey) + addressReservationRepo.clearContactAssignment(normalizedKey) + updateDeletedContactCleanupPending(normalizedKey, false) + } + } + + suspend fun disableSharingAndClearLocalState(savedPublicKeys: Collection): Result = + withContext(serializedDispatcher) { + runCatching { + resetInFlightWork() + removePublishedEndpoints().onFailure { + updateContactSharingCleanupPending(true) + Logger.warn("Failed to remove private Paykit endpoints before clearing state", it, context = TAG) + }.getOrThrow() + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) + } + } + + suspend fun setContactSharingCleanupPending(isPending: Boolean): Result = + withContext(serializedDispatcher) { + runCatching { + updateContactSharingCleanupPending(isPending) + } + } + + suspend fun removePublishedEndpointsBestEffort(context: String): Result = withContext(serializedDispatcher) { + removePublishedEndpoints() + .onFailure { + Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG) + } + } + + suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { + runCatching { + publicationMutex.withLock { + linkEstablishmentMutex.withLock { + resetInFlightWork() + closeActiveHandles() + activeHandlesByContact.clear() + knownSavedContactKeys.clear() + state = PrivatePaykitState() + keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) + cacheStore.reset() + addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) + notifyBackupStateChanged() + } + } + } + } + + suspend fun beginSavedContactPayment(publicKey: String): Result = + withContext(serializedDispatcher) { + runCatching { + val normalizedKey = knownSavedContact(publicKey) + ?: return@runCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + + val privateResult = runCatching { beginPrivatePayment(normalizedKey).getOrThrow() } + .onFailure { + if (it is CancellationException) throw it + Logger.warn( + "Falling back to public Paykit for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + } + .getOrNull() + + if (privateResult is PublicPaykitPaymentResult.Opened) return@runCatching privateResult + publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() + } + } + + suspend fun resolveSavedContactPayableEndpoint(publicKey: String): Result = + withContext(serializedDispatcher) { + runCatching { + val normalizedKey = knownSavedContact(publicKey) + ?: return@runCatching publicPaykitRepo.hasPayablePublicEndpoint(publicKey).getOrThrow() + + val hadCachedPrivateEndpoint = hasCachedPrivateEndpoint(normalizedKey) + val generation = currentStateGeneration() + val linkId = establishedLinkId(normalizedKey, maxAdvanceSteps = 3, generation = generation).getOrNull() + if (linkId == null) { + return@runCatching hadCachedPrivateEndpoint || + publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() + } + + if (ensureState().contacts[normalizedKey]?.lastLocalPayloadHash == null) { + publishLocalEndpointsBestEffort( + publicKey = normalizedKey, + linkId = linkId, + fetchedRemoteCount = 0, + context = "resolve", + generation = generation, + ) + } + val fetchedCount = fetchRemoteEndpoints(normalizedKey, linkId, generation).getOrElse { + Logger.warn( + "Failed to resolve private Paykit endpoints for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + if (hadCachedPrivateEndpoint && !shouldCountAsStaleLinkFailure(it)) { + return@runCatching true + } + return@runCatching publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() + } + val publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?: linkId + publishLocalEndpointsBestEffort( + publicKey = normalizedKey, + linkId = publishLinkId, + fetchedRemoteCount = fetchedCount, + context = "resolve", + generation = generation, + respectInitialPublishDelay = false, + ) + + hasCachedPrivateEndpoint(normalizedKey) || + publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() + } + } + + suspend fun discardRemoteLightningEndpoints( + 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 + val normalizedHashes = paymentHashes.map { it.lowercase() }.toSet() + val filteredEntries = contactState.remoteEndpoints.filterNot { + shouldDiscardRemoteLightningEntry(it, normalizedHashes) + } + if (filteredEntries.size == contactState.remoteEndpoints.size) return@runCatching + + contactState.remoteEndpoints = filteredEntries + persistState(markWalletBackup = true) + } + } + + suspend fun discardRemoteOnchainEndpoints( + 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 + val filteredEntries = contactState.remoteEndpoints.filterNot { + shouldDiscardRemoteOnchainEntry(it, addresses) + } + if (filteredEntries.size == contactState.remoteEndpoints.size) return@runCatching + + contactState.remoteEndpoints = filteredEntries + persistState(markWalletBackup = true) + } + } + + suspend fun handleReceivedPayment(paymentHash: String): Result = withContext(serializedDispatcher) { + runCatching { + val matchingContacts = ensureState().contacts + .filter { (publicKey, contactState) -> + publicKey in knownSavedContactKeys && contactState.localInvoice?.paymentHash == paymentHash + } + .keys + if (matchingContacts.isEmpty()) return@runCatching + + 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, + ) + } + } + } + } + + suspend fun reconcileReceivedPayments(): Result = withContext(serializedDispatcher) { + runCatching { + settledPrivateInvoicePaymentHashes().forEach { + handleReceivedPayment(it).getOrThrow() + } + } + } + + suspend fun handleOnchainActivity(receivedAddresses: Collection = emptyList()): Result = + withContext(serializedDispatcher) { + runCatching { + if (!canPublishPrivateEndpoints()) return@runCatching + val publicKeys = if (receivedAddresses.isEmpty()) { + addressReservationRepo.contactsWithUsedReservedAddresses() + } else { + receivedAddresses.mapNotNull { + addressReservationRepo.currentContactPublicKeyForReservedAddress(it) + } + }.filter { it in knownSavedContactKeys }.distinct() + if (publicKeys.isEmpty()) return@runCatching + + publicKeys.forEach { + addressReservationRepo.rotateAddress(it).getOrThrow() + } + publishLocalEndpoints(publicKeys, maxAdvanceSteps = 1, reason = "on-chain rotation").getOrThrow() + } + } + + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = + withContext(serializedDispatcher) { + if (paymentHash.isBlank()) return@withContext null + ensureState().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + publicKey.takeIf { + contactState.localInvoice?.paymentHash == paymentHash || + paymentHash in contactState.receivedInvoicePaymentHashes + } + } + } + + suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = + withContext(serializedDispatcher) { + addresses.firstNotNullOfOrNull { + addressReservationRepo.contactPublicKeyForReservedAddress(it) + } + } + + 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, + ) + }.toMap().takeIf { it.isNotEmpty() } + } + } + + suspend fun restoreBackup(backup: Map?): Result = + withContext(serializedDispatcher) { + runCatching { + publicationMutex.withLock { + linkEstablishmentMutex.withLock { + resetInFlightWork() + closeActiveHandles() + activeHandlesByContact.clear() + knownSavedContactKeys.clear() + + if (backup == null) { + state = PrivatePaykitState() + persistState() + 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 = storedPaymentEntries(contactBackup.remoteEndpoints), + linkCompletedAt = contactBackup.linkCompletedAt, + handshakeUpdatedAt = contactBackup.handshakeUpdatedAt, + recoveryStartedAt = contactBackup.recoveryStartedAt, + mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId, + responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId, + ) + }.toMap() + + state = PrivatePaykitState(contacts = contacts.toMutableMap()) + } + } + persistState() + notifyBackupStateChanged() + } + } + + private suspend fun beginPrivatePayment(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, + ) + } + + val fetchedCount = fetchRemoteEndpoints(publicKey, linkId, generation).getOrElse { + if (shouldCountAsStaleLinkFailure(it)) throw it + Logger.warn( + "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", + it, + context = TAG, + ) + 0 + } + val publishLinkId = activeHandlesByContact[publicKey]?.linkId ?: linkId + publishLocalEndpointsBestEffort( + publicKey = publicKey, + linkId = publishLinkId, + fetchedRemoteCount = fetchedCount, + context = "payment", + generation = generation, + respectInitialPublishDelay = false, + ) + + 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@runCatching when { + cachedEntries.isEmpty() -> PublicPaykitPaymentResult.NoEndpoint + else -> PublicPaykitPaymentResult.NotOpened + } + } + + PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable)) + } + } + + @Suppress("CyclomaticComplexMethod") + private suspend fun publishLocalEndpoints( + publicKeys: Collection, + maxAdvanceSteps: Int, + reason: String, + scheduleRetries: Boolean = true, + forceLocalPublishWhenRemoteEmpty: Boolean = false, + ): Result = withContext(serializedDispatcher) { + runCatching { + val generation = currentStateGeneration() + 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, + ) ?: return@forEach + + if (publishLocalEndpointsBeforeFetch(normalizedKey, linkId, reason, scheduleRetries, generation)) { + return@forEach + } + + val fetchedCount = fetchRemoteEndpointCountForPublish( + publicKey = normalizedKey, + linkId = linkId, + reason = reason, + scheduleRetries = scheduleRetries, + generation = generation, + ) ?: return@forEach + 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, + ).onFailure { + if (scheduleRetries) schedulePendingPublicationRetry(normalizedKey) + Logger.warn( + "Failed to publish private Paykit endpoints during '$reason' for '$redactedKey'", + it, + context = TAG, + ) + } + val updatedContactState = ensureState().contacts[normalizedKey] + val needsRetry = publishResult.isFailure || + updatedContactState?.linkCompletedAt == null || + updatedContactState.lastLocalPayloadHash == null || + (fetchedCount == 0 && updatedContactState.remoteEndpoints.isEmpty()) + if (scheduleRetries && needsRetry) { + schedulePendingPublicationRetry(normalizedKey) + } else { + cancelPendingPublicationRetry(normalizedKey) + } + } + } + } + + 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 = 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'" + }, + 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, + ) + } + if (publishResult.isFailure) return false + + if (scheduleRetries) schedulePendingPublicationRetry(publicKey) + return true + } + + private suspend fun fetchRemoteEndpointCountForPublish( + publicKey: String, + linkId: String, + reason: String, + scheduleRetries: Boolean, + generation: Long, + ): Int? = fetchRemoteEndpoints(publicKey, linkId, generation).fold( + onSuccess = { it }, + onFailure = { + if (scheduleRetries) { + schedulePendingPublicationRetry(publicKey) + } + Logger.warn( + "Failed to fetch private Paykit endpoints during '$reason' for '${redacted(publicKey)}'", + it, + context = TAG, + ) + if (shouldCountAsStaleLinkFailure(it)) null else 0 + }, + ) + + private suspend fun publishLocalEndpointsBestEffort( + publicKey: String, + linkId: String, + fetchedRemoteCount: Int, + context: String, + generation: Long = currentStateGeneration(), + respectInitialPublishDelay: Boolean = true, + ) { + if (!canPublishPrivateEndpoints()) return + if (!shouldPublishLocalEndpoints(publicKey, fetchedRemoteCount)) return + if (respectInitialPublishDelay && shouldDeferInitialLocalPublish(publicKey, fetchedRemoteCount)) return + + publishLocalEndpoints(publicKey, linkId, generation = generation).onFailure { + schedulePendingPublicationRetry(publicKey) + Logger.warn( + "Failed to publish private Paykit endpoints during '$context' for '${redacted(publicKey)}'", + it, + 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() + if (needsRetry) schedulePendingPublicationRetry(publicKey, remainingAttempts - 1) + } + } + + private fun cancelPendingPublicationRetry(publicKey: String) { + pendingPublicationRetryJobs.remove(publicKey)?.cancel() + } + + private fun resetInFlightWork() { + advanceStateGeneration() + pendingPublicationRetryJobs.values.forEach { it.cancel() } + pendingPublicationRetryJobs.clear() + } + + private fun advanceStateGeneration() { + stateGeneration.incrementAndGet() + } + + private fun currentStateGeneration(): Long = stateGeneration.get() + + private fun ensureCurrentGeneration(generation: Long) { + if (stateGeneration.get() != generation) throw PrivatePaykitError.PrivateUnavailable + } + + private suspend fun publishLocalEndpoints( + publicKey: String, + linkId: String, + force: Boolean = false, + generation: Long = currentStateGeneration(), + ): Result = withContext(serializedDispatcher) { + runCatching { + publicationMutex.withLock { + ensureCurrentGeneration(generation) + if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock + + val endpoints = buildLocalEndpoints(publicKey).getOrThrow() + ensureCurrentGeneration(generation) + val entries = entriesWithinNoiseLimit(endpoints, publicKey) + val payloadHash = localPayloadHash(entries) + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + if (!force && contactState.lastLocalPayloadHash == payloadHash) 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) + } + }.onFailure { + recordLinkFailure(publicKey, it, generation) + } + } + + private suspend fun buildLocalEndpoints(publicKey: String): Result> = + withContext(serializedDispatcher) { + runCatching { + val endpoints = mutableListOf() + val reservedAddress = addressReservationRepo.currentOrRotatedAddress(publicKey).getOrThrow() + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() + endpoints += Endpoint( + methodId = PublicPaykitRepo.onchainMethodId(reservedAddress), + value = reservedAddress, + rawPayload = PublicPaykitRepo.serializePayload(reservedAddress), + ) + + if (lightningRepo.canReceive()) { + currentOrRotatedInvoice(publicKey).onSuccess { invoice -> + endpoints += Endpoint( + methodId = MethodId.Bolt11, + value = invoice.bolt11, + rawPayload = PublicPaykitRepo.serializePayload(invoice.bolt11), + ) + }.onFailure { + ensureState().contacts[publicKey]?.localInvoice = null + persistState() + Logger.warn( + "Failed to prepare private Paykit invoice for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + } else { + ensureState().contacts[publicKey]?.localInvoice = null + persistState() + } + + endpoints + } + } + + private suspend fun currentOrRotatedInvoice(publicKey: String): Result = + withContext(serializedDispatcher) { + runCatching { + reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + + val bolt11 = lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), + ).getOrThrow() + reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + + val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice + ?: throw PublicPaykitError.InvalidPayload + 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 + return invoice + } + + private suspend fun fetchRemoteEndpoints( + publicKey: String, + linkId: String, + generation: Long = currentStateGeneration(), + ): Result = + withContext(serializedDispatcher) { + runCatching { + readRemoteEndpoints(publicKey, linkId, generation).getOrElse { error -> + if (!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 remoteEntries = pubkyService.getPrivatePayments(linkId) + ensureCurrentGeneration(generation) + recordLinkSuccess(publicKey) + persistLinkSnapshot(linkId, publicKey, linkWasReplaced = false, generation = generation).getOrThrow() + ensureCurrentGeneration(generation) + if (remoteEntries.isEmpty()) return@runCatching 0 + + ensureState().contacts.getOrPut(publicKey) { 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) + } + } + } + + @Suppress( + "LongMethod", + "CyclomaticComplexMethod", + "ReturnCount", + "NestedBlockDepth", + "ComplexCondition", + "ThrowsCount", + ) + private suspend fun establishedLinkIdUnlocked( + 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 = 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 restoredLinkId = runCatching { + validateSnapshot(snapshot, normalizedKey, pubkyService::encryptedLinkSnapshotRecipient) + 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)}'", + it, + context = TAG, + ) + contactState.linkSnapshotHex = null + contactState.handshakeSnapshotHex = null + contactState.lastLocalPayloadHash = null + contactState.mainRecoveryAttemptId = null + contactState.responderRecoveryAttemptId = null + persistState(markWalletBackup = true) + }.getOrNull() + if (restoredLinkId != null) { + val remoteRecoveryMarker = 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 = freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_INIT), + ) + val remoteRecoveryInitMarker = fetchedRemoteRecoveryInitMarker + ?.takeUnless { isCompletedRecoveryMarker(it, normalizedKey) } + val remoteRecoveryFinalForResponder = contactState.responderRecoveryAttemptId?.let { + 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 { + 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 + } + + if (shouldAcceptRemoteRecovery && remoteRecoveryMarker != null) { + val isNewResponderAttempt = contactState.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId + if (isNewResponderAttempt) { + if (!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() + persistState(markWalletBackup = true) + } + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_RESPONSE, + attemptId = remoteRecoveryMarker.attemptId, + createdAt = clock.now().epochSeconds, + ) + } + + val shouldInitiateRecovery = isRecovering && !shouldAcceptRemoteRecovery + if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId == null) { + if (!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() + persistState(markWalletBackup = true) + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = attemptId, + createdAt = createdAt, + ) + } + + if ( + shouldInitiateRecovery && + initialMainRecoveryAttemptId != null && + contactState.mainRecoveryAttemptId != null && + localMainRecoveryMarker == null + ) { + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = checkNotNull(contactState.mainRecoveryAttemptId), + createdAt = clock.now().epochSeconds, + ) + } + + 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) + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = attemptId, + createdAt = clock.now().epochSeconds, + ) + val hasPeerProgress = freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_RESPONSE, RECOVERY_MARKER_STAGE_FINAL), + attemptId = attemptId, + ) != null + if (!hasPeerProgress) return null + } + + if ( + shouldAcceptRemoteRecovery && + contactState.responderRecoveryAttemptId != null && + contactState.handshakeSnapshotHex != null + ) { + val attemptId = checkNotNull(contactState.responderRecoveryAttemptId) + val hasPeerFinal = freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_FINAL), + attemptId = attemptId, + ) != null + if (!hasPeerFinal) { + 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 -> + runCatching { + validateSnapshot( + snapshotHex = snapshot, + publicKey = normalizedKey, + recipient = pubkyService::encryptedLinkHandshakeSnapshotRecipient, + ) + 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 (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 (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) { + 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) { + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = checkNotNull(contactState.mainRecoveryAttemptId), + createdAt = createdAt, + ) + } else if (shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { + publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_RESPONSE, + attemptId = checkNotNull(contactState.responderRecoveryAttemptId), + createdAt = createdAt, + ) + } + return null + } + } + + return null + } + + 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 + } + } + + 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 = privateEndpointRemovalEntries() + 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 { clearRecoveryMarker(from = it, to = publicKey) } + } + } + Unit + }.onFailure { + recordLinkFailure(publicKey, it, generation) + } + } + + private suspend fun clearUnsavedContactState(savedPublicKeys: Collection): Result = + 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 { + clearContactState(it) + } + addressReservationRepo.clearContactAssignments(excludingPublicKeys = savedKeys) + } + } + + private suspend fun clearContactState(publicKey: String) { + cancelPendingPublicationRetry(publicKey) + pubkyService.currentPublicKey() + ?.let { PubkyPublicKeyFormat.normalized(it) } + ?.let { 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 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 + } + 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() + val staleLightningHashes = mutableSetOf() + val reusable = payable.filter { endpoint -> + when { + endpoint.methodId == MethodId.Bolt11 -> { + val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return@filter false + if (paymentHash in attemptedHashes) { + staleLightningHashes += paymentHash + Logger.warn( + "Ignoring already-attempted private Paykit invoice for '${redacted(publicKey)}'", + context = TAG, + ) + false + } else { + true + } + } + endpoint.methodId.isOnchain -> { + val isUsed = runCatching { coreService.isAddressUsed(endpoint.value) } + .onFailure { + Logger.warn( + "Failed to check private Paykit endpoint usage for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + .getOrDefault(true) + !isUsed + } + else -> true + } + } + + if (staleLightningHashes.isNotEmpty()) { + discardRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { + Logger.warn( + "Failed to discard already-attempted private Paykit invoice for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + } + return reusable + } + + private suspend fun hasCachedPrivateEndpoint(publicKey: String): Boolean { + val endpoints = ensureState().contacts[publicKey]?.remoteEndpoints.orEmpty().mapNotNull { + PublicPaykitRepo.parseEndpoint(it.methodId, it.endpointData) + } + return privatePayableEndpoints(endpoints, publicKey).isNotEmpty() + } + + private suspend fun shouldDiscardRemoteLightningEntry( + entry: StoredPaymentEntry, + paymentHashes: Set, + ): Boolean { + if (entry.methodId != MethodId.Bolt11.rawValue) return false + val endpoint = PublicPaykitRepo.parseEndpoint(entry.methodId, entry.endpointData) ?: return false + val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false + return paymentHash in paymentHashes + } + + private fun shouldDiscardRemoteOnchainEntry( + entry: StoredPaymentEntry, + addresses: Set, + ): Boolean { + val endpoint = PublicPaykitRepo.parseEndpoint(entry.methodId, entry.endpointData) ?: return false + if (!endpoint.methodId.isOnchain) return false + return endpoint.value in addresses + } + + private suspend fun canPublishPrivateEndpoints(): Boolean { + val settings = settingsStore.data.first() + return settings.sharesPublicPaykitEndpoints && + App.currentActivity?.value != null && + walletRepo.walletExists() && + lightningRepo.lightningState.value.nodeLifecycleState.isRunning() + } + + private suspend fun isContactSharingCleanupPending(): Boolean = + cacheStore.data.first().cleanupPending + + private suspend fun updateContactSharingCleanupPending(isPending: Boolean) { + cacheStore.update { it.copy(cleanupPending = isPending) } + } + + private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = + cacheStore.data.first().deletedContactCleanupPendingPublicKeys + + private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) { + cacheStore.update { + val pendingKeys = if (isPending) { + it.deletedContactCleanupPendingPublicKeys + publicKey + } else { + it.deletedContactCleanupPendingPublicKeys - publicKey + } + it.copy(deletedContactCleanupPendingPublicKeys = pendingKeys) + } + } + + private suspend fun retryPendingDeletedContactEndpointRemoval( + savedPublicKeys: Collection, + ): Result = withContext(serializedDispatcher) { + runCatching { + val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() + pendingDeletedContactCleanupPublicKeys().forEach { publicKey -> + if (publicKey in savedKeys) { + updateDeletedContactCleanupPending(publicKey, false) + return@forEach + } + removePublishedEndpoints(publicKey).getOrThrow() + clearContactState(publicKey) + addressReservationRepo.clearContactAssignment(publicKey) + updateDeletedContactCleanupPending(publicKey, false) + } + } + } + + private fun shouldRequirePrivateEndpointRemoval(publicKey: String): Boolean { + val contactState = state?.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 + } + 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 = + state?.contacts?.get(publicKey)?.lastCompletedRecoveryAttemptId == marker.attemptId + + private fun shouldReplaceUsableLink(marker: RecoveryMarker, publicKey: String): Boolean { + if (isCompletedRecoveryMarker(marker, publicKey)) return false + val linkCompletedAt = state?.contacts?.get(publicKey)?.linkCompletedAt ?: return true + return marker.createdAt > linkCompletedAt + COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS + } + + @Suppress("ReturnCount") + private 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 contactKey = listOf(from, to) + .mapNotNull { normalizedPublicKey(it) } + .firstOrNull { ensureState().contacts[it] != null } + val linkCompletedAt = contactKey?.let { ensureState().contacts[it]?.linkCompletedAt } ?: 0L + if (marker.createdAt <= linkCompletedAt) return null + if (attemptId != null && marker.attemptId != attemptId) return null + return marker + } + + private 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, + ) + } + } + + private 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") + private suspend fun purgePrivatePaymentOutbox(publicKey: String, reason: String): Boolean { + val otherContactCount = ensureState().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 + } + + 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 { entry -> + if (deletedSoFar + deletedCount >= PRIVATE_STORAGE_PURGE_MAX_ENTRIES) { + didHitLimit = true + return@forEach + } + val path = privateStoragePath(entry) ?: 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 { + if (!isMissingPrivateStorageError(it)) 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 String.withTrailingSlash(): String = if (endsWith("/")) this else "$this/" + + private fun isMissingPrivateStorageError(error: Throwable): Boolean { + val reason = error.message.orEmpty().lowercase() + return "404" in reason && "not found" in reason + } + + 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 suspend fun entriesWithinNoiseLimit( + endpoints: List, + publicKey: String, + ): List { + val entries = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } + if (isNoisePayloadWithinLimit(entries)) return entries + + val onchainOnlyEntries = entries.filter { it.methodId != MethodId.Bolt11.rawValue } + if (onchainOnlyEntries.size < entries.size && onchainOnlyEntries.isNotEmpty()) { + if (isNoisePayloadWithinLimit(onchainOnlyEntries)) { + ensureState().contacts[publicKey]?.localInvoice = null + Logger.warn( + "Published private Paykit on-chain only for '${redacted(publicKey)}'", + context = TAG, + ) + return onchainOnlyEntries + } + } + + throw PrivatePaykitError.PayloadTooLarge + } + + private fun privateEndpointRemovalEntries(): List = + MethodId.entries + .filter { it.isBitkitManaged } + .map { StoredPaymentEntry(it.rawValue, PRIVATE_ENDPOINT_REMOVAL_PAYLOAD) } + + private fun validateNoisePayload(entries: List) { + if (!isNoisePayloadWithinLimit(entries)) throw PrivatePaykitError.PayloadTooLarge + } + + private fun isNoisePayloadWithinLimit(entries: List): Boolean { + val payload = entries.associate { it.methodId to it.endpointData } + return noisePayloadJson.encodeToString(payload).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES + } + + private 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) } + } + + private suspend fun settledPrivateInvoicePaymentHashes(): List { + val settled = receivedSettledPaymentHashes() + return ensureState().contacts.values.mapNotNull { it.localInvoice?.paymentHash?.takeIf(settled::contains) } + } + + private suspend fun paymentHashForBolt11(bolt11: String): String? = + runCatching { + (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice?.paymentHash?.toHex() + }.getOrNull() + + private suspend fun attemptedOutboundBolt11PaymentHashes(): Set = + lightningRepo.getPayments().getOrDefault(emptyList()) + .filter { + it.direction == PaymentDirection.OUTBOUND && + it.status != PaymentStatus.FAILED && + it.kind is PaymentKind.Bolt11 + } + .map { it.id.lowercase() } + .toSet() + + private suspend fun isReceivedInvoiceSettled(paymentHash: String): Boolean = + paymentHash in receivedSettledPaymentHashes() + + private suspend fun receivedSettledPaymentHashes(): Set = + lightningRepo.getPayments().getOrDefault(emptyList()) + .filter { + it.direction == PaymentDirection.INBOUND && + it.status == PaymentStatus.SUCCEEDED && + it.kind is PaymentKind.Bolt11 + } + .map { it.id } + .toSet() + + private suspend fun rememberReceivedInvoicePaymentHash(paymentHash: String, publicKey: String) { + if (paymentHash.isBlank()) return + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + if (paymentHash in contactState.receivedInvoicePaymentHashes) return + contactState.receivedInvoicePaymentHashes = + (contactState.receivedInvoicePaymentHashes + paymentHash) + .takeLast(MAX_RECEIVED_INVOICE_HASHES_PER_CONTACT) + persistState() + } + + private suspend fun recordLinkFailure(publicKey: String, error: Throwable, generation: Long? = null) { + if (generation != null && stateGeneration.get() != generation) return + if (!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 + 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 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) } + } + + private fun shouldRetryLinkEstablishmentFailure(error: Throwable): Boolean = + error.causes().none { + it is PrivatePaykitError.PrivateUnavailable || it is PrivatePaykitError.StaleLinkState + } + + 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", "noise state", "counter", "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 } + } + + private 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 } + } + + private fun isEncryptedHandshakePendingError(error: Throwable): Boolean { + val reason = error.message.orEmpty().lowercase() + return "transition_transport failed" in reason && "ishandshake" in reason + } + + 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, + ) { + val snapshotRecipient = recipient(snapshotHex) + if (PubkyPublicKeyFormat.normalized(snapshotRecipient) != PubkyPublicKeyFormat.normalized(publicKey)) { + throw PrivatePaykitError.PrivateUnavailable + } + } + + private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { + val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() + if (replacing) { + knownSavedContactKeys.clear() + knownSavedContactKeys += normalizedKeys + } else { + knownSavedContactKeys += normalizedKeys + } + return normalizedKeys + } + + private fun knownSavedContact(publicKey: String): String? { + val normalizedKey = normalizedPublicKey(publicKey) ?: return null + return normalizedKey.takeIf { it in knownSavedContactKeys } + } + + private fun normalizedPublicKey(publicKey: String): String? = PubkyPublicKeyFormat.normalized(publicKey) + + private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) + + private fun storedPaymentEntries(endpoints: Map): List = + endpoints.toSortedMap().map { StoredPaymentEntry(it.key, it.value) } + + private suspend fun ensureState(): PrivatePaykitState { + state?.let { return it } + val secretState = runCatching { + keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) + ?.let { json.decodeFromString(it) } + }.getOrNull() ?: PrivatePaykitSecretState() + val cacheState = cacheStore.data.first() + + return PrivatePaykitState(secretState, cacheState).also { state = it } + } + + private suspend fun persistState(markWalletBackup: Boolean = false) { + 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 = stored.cleanupPending, + deletedContactCleanupPendingPublicKeys = stored.deletedContactCleanupPendingPublicKeys, + ) + } + if (markWalletBackup) notifyBackupStateChanged() + }.getOrElse { throw PrivatePaykitError.StatePersistenceFailed(it) } + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } +} + +private data class ContactPaykitHandles( + val linkId: String? = null, + val handshakeId: String? = null, +) + +private data class PrivatePaykitState( + val contacts: MutableMap = mutableMapOf(), +) { + constructor(secretState: PrivatePaykitSecretState, 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, + ) = PrivatePaykitCacheData( + contacts = contacts.mapNotNull { (publicKey, contactState) -> + (publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState } + }.toMap(), + cleanupPending = cleanupPending, + deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys, + ) +} + +private 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 linkFailureCount: Int = 0, +) { + 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, + linkFailureCount = cache.linkFailureCount, + ) + + 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() || + localInvoice != null || + receivedInvoicePaymentHashes.isNotEmpty() || + lastLocalPayloadHash != null || + linkCompletedAt != null || + handshakeUpdatedAt != null || + recoveryStartedAt != null || + mainRecoveryAttemptId != null || + responderRecoveryAttemptId != null || + lastCompletedRecoveryAttemptId != null || + linkFailureCount != 0 + + 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, + linkFailureCount = linkFailureCount, + ) +} + +@Serializable +private data class PrivatePaykitSecretState( + val contacts: Map = emptyMap(), +) + +@Serializable +private data class ContactSecretState( + val linkSnapshotHex: String? = null, + val handshakeSnapshotHex: String? = null, +) { + val hasSecretState: Boolean + get() = linkSnapshotHex != null || handshakeSnapshotHex != null +} + +private data class StoredPaymentEntry( + val methodId: String, + val endpointData: String, +) + +private data class StoredInvoice( + val bolt11: String, + val paymentHash: String, + val expiresAt: Long, +) + +private data class PrivateStoragePurgeResult( + val deletedCount: Int, + val didHitLimit: Boolean, + val didFail: Boolean, +) + +@Serializable +private 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/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index f2ae26db5c..1b666780b1 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -108,6 +108,9 @@ class PubkyRepo @Inject constructor( private val _contacts = MutableStateFlow>(emptyList()) val contacts: StateFlow> = _contacts.asStateFlow() + private val _contactsLoadVersion = MutableStateFlow(0L) + val contactsLoadVersion: StateFlow = _contactsLoadVersion.asStateFlow() + private val _isLoadingContacts = MutableStateFlow(false) val isLoadingContacts: StateFlow = _isLoadingContacts.asStateFlow() @@ -623,6 +626,7 @@ class PubkyRepo @Inject constructor( } } _contacts.update { emptyList() } + markContactsLoaded() Logger.info("Deleted all contacts", context = TAG) } @@ -718,6 +722,7 @@ class PubkyRepo @Inject constructor( return@onSuccess } _contacts.update { loadedContacts } + markContactsLoaded() }.onFailure { Logger.error("Failed to load contacts", it, context = TAG) } @@ -760,6 +765,7 @@ class PubkyRepo @Inject constructor( (current.filter { it.publicKey != prefixedKey } + profile) .sortedBy { it.name.lowercase() } } + markContactsLoaded() Logger.info("Added contact '$prefixedKey'", context = TAG) } } @@ -793,6 +799,7 @@ class PubkyRepo @Inject constructor( current.map { if (it.publicKey == prefixedKey) updatedProfile else it } .sortedBy { it.name.lowercase() } } + markContactsLoaded() Logger.info("Updated contact '$prefixedKey'", context = TAG) } } @@ -805,6 +812,7 @@ class PubkyRepo @Inject constructor( val prefixedKey = publicKey.ensurePubkyPrefix() pubkyService.sessionDelete(session, "${Env.contactsBasePath}$prefixedKey") _contacts.update { current -> current.filter { it.publicKey != prefixedKey } } + markContactsLoaded() Logger.info("Removed contact '$prefixedKey'", context = TAG) } } @@ -835,6 +843,7 @@ class PubkyRepo @Inject constructor( (current + imported.filter { it.publicKey !in existing }) .sortedBy { it.name.lowercase() } } + markContactsLoaded() Logger.info("Imported '${imported.size}' contacts", context = TAG) } } @@ -1052,11 +1061,16 @@ class PubkyRepo @Inject constructor( _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } + _contactsLoadVersion.update { 0L } clearPendingImport() _sessionRestorationFailed.update { false } _authState.update { PubkyAuthState.Idle } } + private fun markContactsLoaded() { + _contactsLoadVersion.update { it + 1 } + } + private suspend fun clearLocalState() = withContext(ioDispatcher) { runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index d8de750a6b..490fa7d4b9 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -153,6 +153,10 @@ class PublicPaykitRepo @Inject constructor( } } + suspend fun payableEndpoints(endpoints: List): List = withContext(ioDispatcher) { + endpoints.filter { isPayable(it) } + } + suspend fun syncPublishedEndpoints(publish: Boolean): Result = withContext(ioDispatcher) { runCatching { if (!publish) { @@ -243,6 +247,7 @@ class PublicPaykitRepo @Inject constructor( Result.success(Unit) }.getOrThrow() } + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() val state = walletRepo.walletState.value val endpoints = mutableListOf() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index dde2ee0a44..b23440e1e8 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -25,7 +25,6 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toHex @@ -40,7 +39,6 @@ import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger -import to.bitkit.utils.ServiceError import to.bitkit.utils.measured import javax.inject.Inject import javax.inject.Singleton @@ -54,6 +52,7 @@ class WalletRepo @Inject constructor( private val coreService: CoreService, private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, + private val privatePaykitAddressReservationRepo: PrivatePaykitAddressReservationRepo, private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, @@ -200,7 +199,7 @@ class WalletRepo @Inject constructor( _balanceState.update { balanceState } }.onFailure { if (it !is CancellationException) { - Logger.warn("Could not sync balances", e = it, context = TAG) + Logger.warn("Could not sync balances", it, context = TAG) } } } @@ -262,6 +261,8 @@ class WalletRepo @Inject constructor( val address = getOnchainAddress() if (address.isEmpty()) { newAddress() + } else if (privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(address)) { + newAddress() } else { checkAddressUsage(address).onSuccess { wasUsed -> if (wasUsed) { @@ -347,9 +348,22 @@ class WalletRepo @Inject constructor( } suspend fun newAddress(): Result = withContext(bgDispatcher) { - lightningRepo.newAddress() + privatePaykitAddressReservationRepo.nextReusableReceiveAddress() .onSuccess { address -> setOnchainAddress(address) } - .onFailure { error -> Logger.error("Error generating new address", error, context = TAG) } + .onFailure { error -> Logger.error("Failed to generate new address", error, context = TAG) } + } + + suspend fun refreshReusableReceiveAddressIfReserved(): Result = withContext(bgDispatcher) { + runCatching { + if (!privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(getOnchainAddress())) { + return@runCatching + } + + newAddress().getOrThrow() + updateBip21Url() + }.onFailure { + Logger.error("Failed to refresh reserved receive address", it, context = TAG) + } } suspend fun refreshReceiveAddressAfterTypeChange(): Result = withContext(bgDispatcher) { @@ -371,31 +385,18 @@ class WalletRepo @Inject constructor( addressType: AddressType = AddressType.P2WPKH, ): Result> = withContext(bgDispatcher) { runCatching { - val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) - ?: throw ServiceError.MnemonicNotFound() - - val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name) - - val baseDerivationPath = addressType.toDerivationPath( - index = 0, + val result = lightningRepo.addressInfosForType( + addressType = addressType, isChange = isChange, - ).substringBeforeLast("/0") - - val result = coreService.onchain.deriveBitcoinAddresses( - mnemonicPhrase = mnemonic, - derivationPathStr = baseDerivationPath, - network = Env.network, - bip39Passphrase = passphrase, - isChange = isChange, - startIndex = startIndex.toUInt(), - count = count.toUInt(), - ) + startIndex = startIndex, + count = count, + ).getOrThrow() - val addresses = result.addresses.mapIndexed { index, address -> + val addresses = result.map { address -> AddressModel( address = address.address, - index = startIndex + index, - path = address.path, + index = address.index, + path = addressType.toDerivationPath(index = address.index, isChange = isChange), ) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 8baa73bb5c..57cfa99040 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -3,6 +3,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails @@ -76,9 +77,13 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid +import to.bitkit.models.ALL_ADDRESS_TYPES +import to.bitkit.models.DEFAULT_ADDRESS_TYPE import to.bitkit.models.addressTypeFromAddress import to.bitkit.models.msatFloorOf +import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork +import to.bitkit.models.toSettingsString import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError @@ -215,6 +220,17 @@ class ActivityService( private val lightningService: LightningService, private val settingsStore: SettingsStore, ) { + private var privateInvoiceContactResolver: (suspend (String) -> String?)? = null + private var privateOnchainAddressContactResolver: (suspend (String) -> String?)? = null + + fun setPrivatePaykitContactResolvers( + invoice: (suspend (String) -> String?)?, + onchainAddress: (suspend (String) -> String?)?, + ) { + privateInvoiceContactResolver = invoice + privateOnchainAddressContactResolver = onchainAddress + } + suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one @@ -394,7 +410,7 @@ class ActivityService( } suspend fun handlePaymentEvent(paymentHash: String) = ServiceQueue.CORE.background { - val payments = lightningService.payments ?: run { + val payments = lightningService.listPayments() ?: run { Logger.warn("No payments available for hash $paymentHash", context = TAG) return@background } @@ -469,7 +485,8 @@ class ActivityService( } } - private fun processBolt11( + @Suppress("CyclomaticComplexMethod") + private suspend fun processBolt11( kind: PaymentKind.Bolt11, payment: PaymentDetails, state: PaymentState, @@ -483,17 +500,28 @@ class ActivityService( } val existingActivity = getActivityById(payment.id) - if ( - existingActivity as? Activity.Lightning != null && - (existingActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp - ) { - return + if (existingActivity is Activity.Lightning) { + val statusChanging = existingActivity.v1.status != state + val needsPrivateContactAttribution = existingActivity.v1.contact == null && + payment.direction == PaymentDirection.INBOUND + if ((existingActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && + !statusChanging && + !needsPrivateContactAttribution + ) { + return + } } + val contact = existingActivity + ?.takeIf { it is Activity.Lightning } + ?.let { (it as Activity.Lightning).v1.contact } + ?: privatePaykitContactPublicKeyForReceivedInvoicePaymentHash(payment.id, payment.direction) + val ln = if (existingActivity is Activity.Lightning) { existingActivity.v1.copy( updatedAt = payment.latestUpdateTimestamp, - status = state + status = state, + contact = contact, ) } else { LightningActivity.create( @@ -506,6 +534,7 @@ class ActivityService( fee = msatFloorOf(payment.feePaidMsat ?: 0u), message = kind.description.orEmpty(), preimage = kind.preimage, + contact = contact, seenAt = null, ) } @@ -524,7 +553,7 @@ class ActivityService( private suspend fun fetchTransactionDetails(txid: String): BitkitCoreTransactionDetails? = runCatching { getTransactionDetails(txid) }.onFailure { - Logger.warn("Failed to fetch stored transaction details for $txid: $it", context = TAG) + Logger.warn("Failed to fetch stored transaction details for '$txid'", it, context = TAG) }.getOrNull() private suspend fun findAddressInPreActivityMetadata(details: BitkitCoreTransactionDetails): String? { @@ -549,22 +578,196 @@ class ActivityService( private suspend fun resolveAddressForInboundPayment( kind: PaymentKind.Onchain, - existingActivity: Activity?, payment: PaymentDetails, transactionDetails: BitkitCoreTransactionDetails? = null, ): String? { - if (existingActivity != null || payment.direction != PaymentDirection.INBOUND) { - return null - } + if (payment.direction != PaymentDirection.INBOUND) return null - // Get transaction details if not provided val details = transactionDetails ?: fetchTransactionDetails(kind.txid) if (details == null) { - Logger.verbose("Transaction details not available for txid: ${kind.txid}", context = TAG) + Logger.verbose( + "Skipped address resolution because transaction details are unavailable for '${kind.txid}'", + context = TAG, + ) return null } - return findAddressInPreActivityMetadata(details) + findAddressInPreActivityMetadata(details)?.let { + return it + } + + val currentWalletAddress = cacheStore.data.first().onchainAddress + val selectedAddressType = settingsStore.data.first().selectedAddressType.toAddressType() ?: DEFAULT_ADDRESS_TYPE + searchReceivingAddressWithLdk( + details = details, + value = payment.amountSats ?: 0u, + currentWalletAddress = currentWalletAddress, + selectedAddressType = selectedAddressType, + )?.let { + return it + } + + return findPrivateReservedAddress(details) + } + + private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { + for (output in details.outputs) { + val address = output.scriptpubkeyAddress ?: continue + if (privatePaykitContactPublicKeyForReservedAddress(address) != null) return address + } + return null + } + + private suspend fun searchReceivingAddressWithLdk( + details: BitkitCoreTransactionDetails, + value: ULong, + currentWalletAddress: String, + selectedAddressType: AddressType, + ): String? { + if (currentWalletAddress.isNotBlank() && matchesTransaction(details, currentWalletAddress)) { + return currentWalletAddress + } + + for (isChange in listOf(false, true)) { + for (addressType in prioritizedAddressTypes(selectedAddressType)) { + searchReceivingAddressForType( + details = details, + value = value, + currentWalletAddress = currentWalletAddress, + addressType = addressType, + isChange = isChange, + )?.let { return it } + } + } + + return null + } + + private suspend fun searchReceivingAddressForType( + details: BitkitCoreTransactionDetails, + value: ULong, + currentWalletAddress: String, + addressType: AddressType, + isChange: Boolean, + ): String? { + val addressTypeKey = addressType.toSettingsString() + val endIndex = addressSearchEndIndex(lastUsedAddressSearchIndex(addressTypeKey, isChange)) + + var index = 0 + var currentAddressBatch: Int? = null + while (index < endIndex) { + val addresses = fetchAddressSearchBatch(addressType, isChange, index, addressTypeKey) ?: return null + + if ( + currentWalletAddress.isNotBlank() && + currentAddressBatch == null && + currentWalletAddress in addresses + ) { + currentAddressBatch = index + } + + findAddressSearchMatch(details, value, addresses)?.let { + saveLastUsedAddressSearchIndex(addressTypeKey, isChange, index) + return it + } + + if (shouldStopAfterCurrentAddressBatch(currentAddressBatch, index)) return null + + index += ADDRESS_SEARCH_BATCH_SIZE + } + + return null + } + + private suspend fun fetchAddressSearchBatch( + addressType: AddressType, + isChange: Boolean, + index: Int, + addressTypeKey: String, + ): List? { + val scope = if (isChange) "change" else "receive" + return runCatching { + lightningService.addressInfosForType( + addressType = addressType, + isChange = isChange, + startIndex = index, + count = ADDRESS_SEARCH_BATCH_SIZE, + ).map { it.address } + }.onFailure { + Logger.warn( + "Skipping '$addressTypeKey' '$scope' address search batch '$index'", + it, + context = TAG, + ) + }.getOrNull() + } + + private fun findAddressSearchMatch( + details: BitkitCoreTransactionDetails, + value: ULong, + addresses: List, + ): String? { + val exactAddress = details.outputs + .firstOrNull { it.value.toULong() == value } + ?.scriptpubkeyAddress + if (exactAddress != null && exactAddress in addresses) { + return exactAddress + } + + return addresses.firstOrNull { matchesTransaction(details, it) } + } + + private fun matchesTransaction(details: BitkitCoreTransactionDetails, address: String): Boolean { + return details.outputs.any { it.scriptpubkeyAddress == address } + } + + private fun addressSearchEndIndex(lastUsed: Int?): Int { + return lastUsed?.let { + if (it > Int.MAX_VALUE - ADDRESS_SEARCH_WINDOW) Int.MAX_VALUE else it + ADDRESS_SEARCH_WINDOW + } ?: ADDRESS_SEARCH_WINDOW + } + + private fun shouldStopAfterCurrentAddressBatch(currentAddressBatch: Int?, index: Int): Boolean { + val found = currentAddressBatch ?: return false + val stopIndex = if (found > Int.MAX_VALUE - ADDRESS_SEARCH_BATCH_SIZE) { + Int.MAX_VALUE + } else { + found + ADDRESS_SEARCH_BATCH_SIZE + } + return index >= stopIndex + } + + private suspend fun lastUsedAddressSearchIndex(addressTypeKey: String, isChange: Boolean): Int? { + val cache = cacheStore.data.first() + return if (isChange) { + cache.addressSearchLastUsedChangeIndexes[addressTypeKey] + } else { + cache.addressSearchLastUsedReceiveIndexes[addressTypeKey] + } + } + + private suspend fun saveLastUsedAddressSearchIndex( + addressTypeKey: String, + isChange: Boolean, + index: Int, + ) { + cacheStore.update { + if (isChange) { + val updatedIndexes = it.addressSearchLastUsedChangeIndexes + (addressTypeKey to index) + it.copy( + addressSearchLastUsedChangeIndexes = updatedIndexes, + ) + } else { + val updatedIndexes = it.addressSearchLastUsedReceiveIndexes + (addressTypeKey to index) + it.copy( + addressSearchLastUsedReceiveIndexes = updatedIndexes, + ) + } + } + } + + private fun prioritizedAddressTypes(selectedAddressType: AddressType): List { + return listOf(selectedAddressType) + ALL_ADDRESS_TYPES.filter { it != selectedAddressType } } private data class ConfirmationData( @@ -589,11 +792,14 @@ class ActivityService( return ConfirmationData(isConfirmed, blockTimestamp, timestamp) } + @Suppress("LongParameterList") private fun buildUpdatedOnchainActivity( existingActivity: Activity.Onchain, confirmationData: ConfirmationData, ldkValue: ULong, ldkFeeMsat: ULong, + resolvedAddress: String?, + contact: String?, channelId: String? = null, ): OnchainActivity { var preservedIsTransfer = existingActivity.v1.isTransfer @@ -622,6 +828,8 @@ class ActivityService( updatedAt = confirmationData.timestamp, isTransfer = preservedIsTransfer, channelId = preservedChannelId, + address = resolvedAddress ?: existingActivity.v1.address, + contact = contact ?: existingActivity.v1.contact, value = preservedValue, fee = updatedFee, ) @@ -629,11 +837,13 @@ class ActivityService( return updatedOnChain } + @Suppress("LongParameterList") private fun buildNewOnchainActivity( payment: PaymentDetails, kind: PaymentKind.Onchain, confirmationData: ConfirmationData, resolvedAddress: String?, + contact: String?, channelId: String? = null, ): OnchainActivity { val isTransfer = channelId != null @@ -658,11 +868,12 @@ class ActivityService( isTransfer = isTransfer, confirmTimestamp = blockTimestamp, channelId = channelId, + contact = contact, seenAt = null, ) } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "ComplexCondition", "LongMethod") private suspend fun processOnchainPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, @@ -680,9 +891,10 @@ class ActivityService( } } - if (existingActivity != null && - existingActivity is Activity.Onchain && - ((existingActivity as Activity.Onchain).v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp + val existingOnchainActivity = existingActivity as? Activity.Onchain + if (existingOnchainActivity != null && + (existingOnchainActivity.v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp && + !(existingOnchainActivity.v1.contact == null && payment.direction == PaymentDirection.INBOUND) ) { return } @@ -701,15 +913,23 @@ class ActivityService( } } - val resolvedAddress = resolveAddressForInboundPayment(kind, existingActivity, payment, transactionDetails) + val resolvedAddress = resolveAddressForInboundPayment(kind, payment, transactionDetails) + val existingContact = existingOnchainActivity?.v1?.contact + val contact = existingContact ?: if (payment.direction == PaymentDirection.INBOUND) { + resolvedAddress?.let { privatePaykitContactPublicKeyForReservedAddress(it) } + } else { + null + } val ldkValue = payment.amountSats ?: 0u - val onChain = if (existingActivity is Activity.Onchain) { + val onChain = if (existingOnchainActivity != null) { buildUpdatedOnchainActivity( - existingActivity = existingActivity as Activity.Onchain, + existingActivity = existingOnchainActivity, confirmationData = confirmationData, ldkValue = ldkValue, ldkFeeMsat = payment.feePaidMsat ?: 0u, + resolvedAddress = resolvedAddress, + contact = contact, channelId = resolvedChannelId, ) } else { @@ -718,6 +938,7 @@ class ActivityService( kind = kind, confirmationData = confirmationData, resolvedAddress = resolvedAddress, + contact = contact, channelId = resolvedChannelId, ) } @@ -738,6 +959,17 @@ class ActivityService( private fun PaymentDirection.toPaymentType(): PaymentType = if (this == PaymentDirection.OUTBOUND) PaymentType.SENT else PaymentType.RECEIVED + private suspend fun privatePaykitContactPublicKeyForReceivedInvoicePaymentHash( + paymentHash: String, + direction: PaymentDirection, + ): String? { + if (direction != PaymentDirection.INBOUND) return null + return privateInvoiceContactResolver?.invoke(paymentHash) + } + + private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = + privateOnchainAddressContactResolver?.invoke(address) + // MARK: - Test Data Generation (regtest only) @Suppress("LongMethod") @@ -874,7 +1106,7 @@ class ActivityService( val coreDetails = mapToCoreTransactionDetails(txid, details) upsertTransactionDetails(listOf(coreDetails)) - val payments = lightningService.payments ?: run { + val payments = lightningService.listPayments() ?: run { Logger.warn("No payments available for transaction $txid", context = TAG) return@background } @@ -905,7 +1137,7 @@ class ActivityService( val coreDetails = mapToCoreTransactionDetails(txid, details) upsertTransactionDetails(listOf(coreDetails)) - val payments = lightningService.payments ?: run { + val payments = lightningService.listPayments() ?: run { Logger.warn("No payments available for transaction $txid", context = TAG) return@background } @@ -980,7 +1212,7 @@ class ActivityService( var replacementActivity = getOnchainActivityByTxId(conflictTxid) if (replacementActivity == null) { - val payments = lightningService.payments + val payments = lightningService.listPayments() val replacementPayment = payments?.firstOrNull { payment -> (payment.kind as? PaymentKind.Onchain)?.txid == conflictTxid } @@ -1369,6 +1601,8 @@ class ActivityService( companion object { private const val TAG = "ActivityService" + private const val ADDRESS_SEARCH_BATCH_SIZE = 200 + private const val ADDRESS_SEARCH_WINDOW = 1_000 } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 979bb64592..edea8b4970 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -30,6 +30,7 @@ import org.lightningdevkit.ldknode.Config import org.lightningdevkit.ldknode.ElectrumSyncConfig import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.FeeRate +import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus @@ -70,6 +71,11 @@ import org.lightningdevkit.ldknode.AddressType as LdkAddressType typealias NodeEventHandler = suspend (Event) -> Unit +data class AddressDerivationInfo( + val address: String, + val index: Int, +) + @Suppress("LargeClass", "TooManyFunctions") @Singleton class LightningService @Inject constructor( @@ -416,6 +422,62 @@ class LightningService @Inject constructor( } } + suspend fun newAddressForType(addressType: AddressType): String { + val addressInfo = newAddressInfoForType(addressType) + return addressInfo.address + } + + suspend fun newAddressInfoForType(addressType: AddressType): AddressDerivationInfo { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + return ServiceQueue.LDK.background { + val addressInfo = node.onchainPayment().newAddressInfoForType(addressType.toLdkAddressType()) + AddressDerivationInfo(address = addressInfo.address, index = addressInfo.index.toInt()) + } + } + + suspend fun addressInfoForType(addressType: AddressType, receiveIndex: Int): AddressDerivationInfo { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + return ServiceQueue.LDK.background { + val addressInfo = node.onchainPayment().addressInfoForTypeAtIndex( + addressType.toLdkAddressType(), + KeychainKind.EXTERNAL, + receiveIndex.toUInt(), + ) + AddressDerivationInfo(address = addressInfo.address, index = addressInfo.index.toInt()) + } + } + + suspend fun addressInfosForType( + addressType: AddressType, + isChange: Boolean, + startIndex: Int, + count: Int, + ): List { + val node = this.node ?: throw ServiceError.NodeNotSetup() + val keychain = if (isChange) KeychainKind.INTERNAL else KeychainKind.EXTERNAL + + return ServiceQueue.LDK.background { + node.onchainPayment() + .addressInfosForType( + addressType.toLdkAddressType(), + keychain, + startIndex.toUInt(), + count.toUInt(), + ) + .map { AddressDerivationInfo(address = it.address, index = it.index.toInt()) } + } + } + + suspend fun revealReceiveAddresses(toReceiveIndex: Int, forType: AddressType) { + val node = this.node ?: throw ServiceError.NodeNotSetup() + + ServiceQueue.LDK.background { + node.onchainPayment().revealReceiveAddressesTo(forType.toLdkAddressType(), toReceiveIndex.toUInt()) + } + } + // region peers suspend fun connectToTrustedPeers() { val node = this.node ?: throw ServiceError.NodeNotSetup() @@ -584,8 +646,8 @@ class LightningService @Inject constructor( return false } - if (channels.none { it.isChannelReady }) { - Logger.warn("canReceive = false: Found no LN channel ready to enable receive: '$channels'", context = TAG) + if (channels.none { it.isUsable }) { + Logger.warn("canReceive = false: Found no LN channel usable to enable receive: '$channels'", context = TAG) return false } @@ -1028,7 +1090,13 @@ class LightningService @Inject constructor( val config: Config? get() = node?.config() val peers: List? get() = node?.listPeers() val channels: List? get() = node?.listChannels() - val payments: List? get() = node?.listPayments() + + suspend fun listPayments(): List? { + val node = this.node ?: return null + return ServiceQueue.LDK.background { + node.listPayments() + } + } // endregion // region debug diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 702e09ed43..8658336f48 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -19,17 +19,32 @@ 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.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.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 @@ -96,6 +111,11 @@ class PubkyService @Inject constructor( paykitGetPaymentList(publicKey) } + suspend fun getPaymentEndpoint(publicKey: String, methodId: String): String? = ServiceQueue.CORE.background { + isSetup.await() + paykitGetPaymentEndpoint(publicKey, methodId) + } + suspend fun setPaymentEndpoint(methodId: String, endpointData: String) = ServiceQueue.CORE.background { isSetup.await() paykitSetPaymentEndpoint(methodId, endpointData) @@ -108,6 +128,81 @@ class PubkyService @Inject constructor( // 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() + paykitSetPrivatePayments(linkId, entries) + } + + suspend fun getPrivatePayments(linkId: String): List = ServiceQueue.CORE.background { + isSetup.await() + paykitGetPrivatePayments(linkId) + } + + // endregion + // region Key derivation suspend fun mnemonicToSeed(mnemonic: String, passphrase: String?): ByteArray = diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4b2d36b749..1d0b098335 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -256,6 +256,7 @@ fun ContentView( currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() appViewModel.refreshPublicPaykitEndpoints() + appViewModel.refreshPrivatePaykitEndpoints() } Lifecycle.Event.ON_STOP -> { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 1ce9499066..98ce6be92b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -110,7 +110,7 @@ private fun Content( currentProfile != null -> ContactBody( profile = currentProfile, tags = uiState.tags, - hasPublicPaymentEndpoint = uiState.hasPublicPaymentEndpoint, + hasPaymentEndpoint = uiState.hasPaymentEndpoint, onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickPay = onClickPay, @@ -136,7 +136,7 @@ private fun Content( private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, - hasPublicPaymentEndpoint: Boolean, + hasPaymentEndpoint: Boolean, onClickEdit: () -> Unit, onClickCopy: () -> Unit, onClickPay: () -> Unit, @@ -170,7 +170,7 @@ private fun ContactBody( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - if (hasPublicPaymentEndpoint) { + if (hasPaymentEndpoint) { ActionButton( onClick = onClickPay, iconRes = R.drawable.ic_coins, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 247cb763a6..4de7067287 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -23,9 +23,9 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult -import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -34,7 +34,7 @@ import javax.inject.Inject class ContactDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, - private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -64,12 +64,11 @@ class ContactDetailViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } val cached = pubkyRepo.contacts.value.find { it.publicKey == publicKey } if (cached != null) { - val hasEndpoint = loadPaymentEndpoint() _uiState.update { it.copy( profile = cached, tags = cached.tags.toImmutableList(), - hasPublicPaymentEndpoint = hasEndpoint, + hasPaymentEndpoint = true, isLoading = false, ) } @@ -77,12 +76,11 @@ class ContactDetailViewModel @Inject constructor( } pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> - val hasEndpoint = loadPaymentEndpoint() _uiState.update { it.copy( profile = profile, tags = profile.tags.toImmutableList(), - hasPublicPaymentEndpoint = hasEndpoint, + hasPaymentEndpoint = true, isLoading = false, ) } @@ -98,17 +96,9 @@ class ContactDetailViewModel @Inject constructor( } } - private suspend fun loadPaymentEndpoint(): Boolean { - return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) - .onFailure { - Logger.warn("Failed to load public Paykit endpoint for '$redactedPublicKey'", it, context = TAG) - } - .getOrDefault(false) - } - fun payContact() { viewModelScope.launch { - publicPaykitRepo.beginPayment(publicKey) + privatePaykitRepo.beginSavedContactPayment(publicKey) .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> @@ -120,7 +110,7 @@ class ContactDetailViewModel @Inject constructor( } } .onFailure { - Logger.warn("Failed to begin public Paykit payment for '$redactedPublicKey'", it, context = TAG) + Logger.warn("Failed to begin Paykit payment for '$redactedPublicKey'", it, context = TAG) showPayError(R.string.slashtags__error_pay_not_opened_msg) } } @@ -200,7 +190,7 @@ data class ContactDetailUiState( val profile: PubkyProfile? = null, val tags: ImmutableList = persistentListOf(), val isLoading: Boolean = false, - val hasPublicPaymentEndpoint: Boolean = false, + val hasPaymentEndpoint: Boolean = false, val showAddTagSheet: Boolean = false, ) 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 c511627fcd..eaf7bb7d23 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -30,6 +31,7 @@ import javax.inject.Inject class EditProfileViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: PrivatePaykitRepo, ) : ViewModel() { companion object { private const val TAG = "EditProfileViewModel" @@ -225,6 +227,8 @@ class EditProfileViewModel @Inject constructor( fun disconnectProfile() { viewModelScope.launch { _uiState.update { it.copy(showDeleteFailureDialog = false, isSaving = true) } + privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) + privatePaykitRepo.closeAndClear() pubkyRepo.signOut() .onSuccess { _uiState.update { it.copy(isSaving = false) } @@ -250,6 +254,8 @@ class EditProfileViewModel @Inject constructor( isSaving = true, ) } + privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) + privatePaykitRepo.closeAndClear() pubkyRepo.deleteProfileWithSessionRetry() .onSuccess { _uiState.update { it.copy(isSaving = false) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 41cc7fbb82..91f5080da9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -18,6 +18,8 @@ import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -29,6 +31,8 @@ class PayContactsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val settingsStore: SettingsStore, private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, + private val pubkyRepo: PubkyRepo, ) : ViewModel() { companion object { private const val TAG = "PayContactsViewModel" @@ -58,16 +62,17 @@ class PayContactsViewModel @Inject constructor( fun continueToProfile() { viewModelScope.launch { val shouldPublish = _uiState.value.isPaymentSharingEnabled + val contacts = pubkyRepo.contacts.value.map { it.publicKey } _uiState.update { it.copy(isLoading = true) } - publicPaykitRepo.syncPublishedEndpoints(shouldPublish) + val result = if (shouldPublish) { + enableContactPayments(contacts) + } else { + disableContactPayments(contacts) + } + + result .onSuccess { - settingsStore.update { - it.copy( - hasConfirmedPublicPaykitEndpoints = true, - sharesPublicPaykitEndpoints = shouldPublish, - ) - } _uiState.update { it.copy(isLoading = false) } _effects.emit(PayContactsEffect.Continue) } @@ -90,6 +95,72 @@ class PayContactsViewModel @Inject constructor( } } + private suspend fun enableContactPayments(contacts: List): Result { + publicPaykitRepo.syncPublishedEndpoints(publish = true) + .onFailure { return Result.failure(it) } + + runCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + } + }.onFailure { + return Result.failure(it) + } + + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { + Logger.warn("Failed to clear private Paykit cleanup marker", it, context = TAG) + } + privatePaykitRepo.prepareSavedContacts(contacts) + .onFailure { + Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) + } + + return Result.success(Unit) + } + + private suspend fun disableContactPayments(contacts: List): Result { + runCatching { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = false, + ) + } + }.onFailure { + return Result.failure(it) + } + + var cleanupError: Throwable? = null + publicPaykitRepo.syncPublishedEndpoints(publish = false) + .onFailure { + cleanupError = it + Logger.warn("Failed to remove public Paykit endpoints", it, context = TAG) + } + + privatePaykitRepo.disableSharingAndClearLocalState(contacts) + .onFailure { + if (cleanupError == null) cleanupError = it + Logger.warn("Failed to remove private Paykit endpoints", it, context = TAG) + } + + cleanupError?.let { + privatePaykitRepo.setContactSharingCleanupPending(true) + .onFailure { error -> + Logger.warn("Failed to mark private Paykit cleanup pending", error, context = TAG) + } + return Result.failure(it) + } + + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { return Result.failure(it) } + + return Result.success(Unit) + } + private fun syncErrorMessage(error: Throwable): String = when (error) { PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload) PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint) 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 b6fa2c6f9d..0408d847e2 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 @@ -19,6 +19,7 @@ import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -28,6 +29,7 @@ import javax.inject.Inject class ProfileViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: PrivatePaykitRepo, ) : ViewModel() { companion object { private const val TAG = "ProfileViewModel" @@ -75,6 +77,8 @@ class ProfileViewModel @Inject constructor( viewModelScope.launch { _isSigningOut.update { true } _showSignOutDialog.update { false } + privatePaykitRepo.removePublishedEndpointsBestEffort(TAG) + privatePaykitRepo.closeAndClear() pubkyRepo.signOut() .onSuccess { _effects.emit(ProfileEffect.SignedOut) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt index f0833dcf99..9d6924320b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt @@ -27,8 +27,10 @@ import to.bitkit.models.addressTypeInfo import to.bitkit.models.toAddressType import to.bitkit.models.toSettingsString import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -38,7 +40,11 @@ class AddressTypePreferenceViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, + private val privatePaykitRepo: PrivatePaykitRepo, ) : ViewModel() { + companion object { + private const val TAG = "AddressTypePreferenceViewModel" + } private val _uiState = MutableStateFlow(AddressTypePreferenceUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -83,6 +89,10 @@ class AddressTypePreferenceViewModel @Inject constructor( monitoredTypes = currentMonitored.toList(), ).onSuccess { walletRepo.refreshReceiveAddressAfterTypeChange() + privatePaykitRepo.refreshKnownSavedContactEndpoints("address type changed") + .onFailure { + Logger.warn("Failed to refresh private Paykit after address type change", it, context = TAG) + } } _uiState.update { it.copy(isLoading = false) } @@ -122,6 +132,16 @@ class AddressTypePreferenceViewModel @Inject constructor( ) val repoResult = lightningRepo.setMonitoring(addressType, enabled) + .onSuccess { + privatePaykitRepo.refreshKnownSavedContactEndpoints("address monitoring changed") + .onFailure { + Logger.warn( + "Failed to refresh private Paykit after address monitoring changed", + it, + context = TAG, + ) + } + } _uiState.update { it.copy(isLoading = false) } loadState() diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ca1f462ec5..bff60f4137 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -11,11 +11,14 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrivatePaykitAddressReservationRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.utils.Logger import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton @Suppress("LongParameterList") @@ -32,6 +35,8 @@ class WipeWalletUseCase @Inject constructor( private val activityRepo: ActivityRepo, private val lightningRepo: LightningRepo, private val pubkyRepo: PubkyRepo, + private val privatePaykitRepo: Provider, + private val privatePaykitAddressReservationRepo: PrivatePaykitAddressReservationRepo, private val firebaseMessaging: FirebaseMessaging, private val migrationService: MigrationService, ) { @@ -44,6 +49,9 @@ class WipeWalletUseCase @Inject constructor( backupRepo.setWiping(true) backupRepo.reset() + privatePaykitRepo.get().removePublishedEndpointsBestEffort(TAG) + privatePaykitRepo.get().closeAndClear() + privatePaykitAddressReservationRepo.clear() pubkyRepo.removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } pubkyRepo.wipeLocalState() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2e95d70d68..66b329b4ac 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -125,6 +126,7 @@ import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.TransferRepo @@ -189,6 +191,7 @@ class AppViewModel @Inject constructor( private val coreService: CoreService, private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, + private val privatePaykitRepo: PrivatePaykitRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, @@ -256,6 +259,7 @@ class AppViewModel @Inject constructor( } private var isCompletingMigration = false private var addressValidationJob: Job? = null + private var lastPrivatePaykitContactKeys: Set = emptySet() fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -289,6 +293,14 @@ class AppViewModel @Inject constructor( } init { + coreService.activity.setPrivatePaykitContactResolvers( + invoice = { paymentHash -> + privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) + }, + onchainAddress = { address -> + privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(listOf(address)) + }, + ) viewModelScope.launch { ToastEventBus.events.collect { toast(it) @@ -341,6 +353,7 @@ class AppViewModel @Inject constructor( observeLdkNodeEvents() observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() + observePrivatePaykitContacts() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -408,6 +421,10 @@ class AppViewModel @Inject constructor( viewModelScope.launch { refreshPublicPaykitEndpointsIfEnabled() } } + fun refreshPrivatePaykitEndpoints() { + viewModelScope.launch { refreshPrivatePaykitEndpointsIfEnabled("foreground") } + } + private suspend fun refreshPublicPaykitEndpointsIfEnabled() { val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints if (!shouldPublish) return @@ -419,6 +436,64 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } } + private fun observePrivatePaykitContacts() { + viewModelScope.launch { + combine( + pubkyRepo.publicKey, + pubkyRepo.contacts, + pubkyRepo.contactsLoadVersion, + ) { publicKey, contacts, contactsLoadVersion -> + Triple(publicKey, contacts.map { it.publicKey }.toSet(), contactsLoadVersion > 0L) + } + .distinctUntilChanged() + .collect { (publicKey, contactKeys, contactsLoaded) -> + if (publicKey == null) { + lastPrivatePaykitContactKeys = emptySet() + return@collect + } + if (!contactsLoaded) return@collect + + val removedKeys = lastPrivatePaykitContactKeys - contactKeys + removedKeys.forEach { + privatePaykitRepo.removeSavedContact(it) + .onFailure { error -> + Logger.warn( + "Failed to remove private Paykit contact '${PubkyPublicKeyFormat.redacted(it)}'", + error, + context = TAG, + ) + } + } + + privatePaykitRepo.prepareSavedContacts(contactKeys) + .onFailure { + Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) + } + privatePaykitRepo.pruneUnsavedContactState(contactKeys) + .onFailure { + Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) + } + lastPrivatePaykitContactKeys = contactKeys + } + } + } + + private suspend fun refreshPrivatePaykitEndpointsIfEnabled(reason: String) { + privatePaykitRepo.reconcileReservedReceiveIndexes() + .onFailure { + Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) + } + val contactKeys = pubkyRepo.contacts.value.map { it.publicKey } + privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) + .onFailure { + Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) + } + privatePaykitRepo.refreshKnownSavedContactEndpoints(reason) + .onFailure { + Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) + } + } + @Suppress("CyclomaticComplexMethod") private fun handleLdkEvent(event: Event) { if (!walletRepo.walletExists()) return @@ -464,6 +539,7 @@ class AppViewModel @Inject constructor( transferRepo.syncTransferStates() walletRepo.syncBalances() refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("channel ready") notifyChannelReady(event) } @@ -481,6 +557,7 @@ class AppViewModel @Inject constructor( transferRepo.syncTransferStates() walletRepo.syncBalances() refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("channel closed") } private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { @@ -538,6 +615,15 @@ class AppViewModel @Inject constructor( !isShowingLoading && !needsPostMigrationSync && !isCompletingMigration -> walletRepo.debounceSyncByEvent() else -> Unit } + + privatePaykitRepo.reconcileReceivedPayments() + .onFailure { + Logger.warn("Failed to reconcile private Paykit invoices", it, context = TAG) + } + privatePaykitRepo.handleOnchainActivity() + .onFailure { + Logger.warn("Failed to reconcile private Paykit on-chain activity", it, context = TAG) + } } private suspend fun completeRNRemoteBackupRestore() { @@ -668,11 +754,28 @@ class AppViewModel @Inject constructor( } private suspend fun handleOnchainTransactionReceived(event: Event.OnchainTransactionReceived) { + val addresses = event.details.outputs.mapNotNull { it.scriptpubkeyAddress } + val contactPublicKey = privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(addresses) notifyPaymentReceived(event) + if (contactPublicKey != null) { + activityRepo.setContact( + contactPublicKey = contactPublicKey, + forPaymentId = event.txid, + syncLdkPayments = false, + ) + } + privatePaykitRepo.handleOnchainActivity(addresses) + .onFailure { + Logger.warn("Failed to rotate private Paykit address for '${event.txid}'", it, context = TAG) + } } private suspend fun handleOnchainTransactionReorged(event: Event.OnchainTransactionReorged) { activityRepo.handleOnchainTransactionReorged(event.txid) + privatePaykitRepo.handleOnchainActivity() + .onFailure { + Logger.warn("Failed to refresh private Paykit after reorg", it, context = TAG) + } notifyTransactionUnconfirmed() } @@ -685,6 +788,10 @@ class AppViewModel @Inject constructor( ?.let { it.isBoosted && it.txType == PaymentType.SENT } == true activityRepo.handleOnchainTransactionReplaced(event.txid, event.conflicts) + privatePaykitRepo.handleOnchainActivity() + .onFailure { + Logger.warn("Failed to refresh private Paykit after replacement", it, context = TAG) + } if (!shouldSuppressReplacedToast) { notifyTransactionReplaced(event) } @@ -708,6 +815,13 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> + activityRepo.setContact( + contactPublicKey = publicKey, + forPaymentId = paymentHash, + syncLdkPayments = false, + ) + } publicPaykitRepo.refreshPublishedBolt11ForPayment(paymentHash) .onFailure { Logger.warn( @@ -716,6 +830,14 @@ class AppViewModel @Inject constructor( context = TAG, ) } + privatePaykitRepo.handleReceivedPayment(paymentHash) + .onFailure { + Logger.warn( + "Failed to rotate private Paykit invoice for '$paymentHash'", + it, + context = TAG, + ) + } } notifyPaymentReceived(event) } @@ -1494,6 +1616,10 @@ class AppViewModel @Inject constructor( activeContactPaymentContext != null } + private fun activeContactPaymentPublicKey() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.publicKey + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun onScanOnchain( invoice: OnChainInvoice, @@ -1998,9 +2124,11 @@ class AppViewModel @Inject constructor( SendMethod.ONCHAIN -> { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags + val contactPublicKey = activeContactPaymentPublicKey() sendOnchain(address, amount, tags = tags) .onSuccess { txId -> + discardContactOnchainEndpoint(contactPublicKey, address) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -2015,7 +2143,7 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() _successSendUiState.update { it.copy(isLoadingDetails = false) } }.onFailure { e -> - Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) + Logger.error("Error sending onchain payment", e, context = TAG) toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__error_sending_title), @@ -2034,6 +2162,7 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags var createdMetadataPaymentId: String? = null + val contactPublicKey = activeContactPaymentPublicKey() // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() @@ -2052,6 +2181,7 @@ class AppViewModel @Inject constructor( } sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> + discardContactLightningEndpoint(contactPublicKey, actualPaymentHash) Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -2063,12 +2193,16 @@ class AppViewModel @Inject constructor( ) }.onFailure { if (it is PaymentPendingException) { + discardContactLightningEndpoint(contactPublicKey, it.paymentHash) Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) preserveContactPaymentContext(it.paymentHash) setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } + if (contactPublicKey != null && PrivatePaykitRepo.isDuplicatePaymentError(it)) { + discardContactLightningEndpoint(contactPublicKey, paymentHash) + } // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -2675,6 +2809,28 @@ class AppViewModel @Inject constructor( } } + private suspend fun discardContactLightningEndpoint(contactPublicKey: String?, paymentHash: String) { + if (contactPublicKey == null) return + privatePaykitRepo.discardRemoteLightningEndpoints(contactPublicKey, setOf(paymentHash)).onFailure { + Logger.warn( + "Failed to discard private Paykit invoice for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", + it, + context = TAG, + ) + } + } + + private suspend fun discardContactOnchainEndpoint(contactPublicKey: String?, address: String) { + if (contactPublicKey == null) return + privatePaykitRepo.discardRemoteOnchainEndpoints(contactPublicKey, setOf(address)).onFailure { + Logger.warn( + "Failed to discard private Paykit address for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", + it, + context = TAG, + ) + } + } + fun handleDeeplinkIntent(intent: Intent) { if (intent.action != Intent.ACTION_VIEW) return intent.data?.let { uri -> @@ -2774,7 +2930,7 @@ class AppViewModel @Inject constructor( ) } }.onFailure { e -> - Logger.warn("Failure fetching new releases", e = e, context = TAG) + Logger.warn("Failure fetching new releases", e, context = TAG) } } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 7240d6dd7d..9a33118537 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt @@ -110,7 +110,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Simulate activity update whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.success(updatedActivity)) - activitiesChangedFlow.value = System.currentTimeMillis() + activitiesChangedFlow.value += 1 // Verify ViewModel reflects updated activity val updatedState = sut.uiState.value.activityLoadState @@ -135,7 +135,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Trigger activity change val callCountBefore = mockingDetails(activityRepo).invocations.size - activitiesChangedFlow.value = System.currentTimeMillis() + activitiesChangedFlow.value += 1 // Verify no reload after clear (getActivity not called again) val callCountAfter = mockingDetails(activityRepo).invocations.size @@ -156,7 +156,7 @@ class ActivityDetailViewModelTest : BaseUnitTest() { // Simulate reload failure whenever(activityRepo.getActivity(ACTIVITY_ID)).thenReturn(Result.failure(Exception("Network error"))) - activitiesChangedFlow.value = System.currentTimeMillis() + activitiesChangedFlow.value += 1 // Verify last known state is preserved val state = sut.uiState.value.activityLoadState diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index 8cbdcdf19c..d05b3b4f28 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -5,9 +5,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData @@ -17,6 +17,7 @@ import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.services.CurrencyService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import java.math.BigDecimal import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -183,13 +184,16 @@ class CurrencyRepoTest : BaseUnitTest() { fun `should detect stale data based on lastUpdatedAt`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + whenever(cacheStore.update(any())).thenReturn(Unit) + whenever(currencyService.fetchLatestRates()) + .thenReturn(testRates) + .thenAnswer { throw CurrencyRepoTestError("API error") } sut = createSut() - whenever(clock.now()).thenReturn(Clock.System.now().minus(10.minutes)) + val now = Clock.System.now() + whenever(clock.now()).thenReturn(now.minus(11.minutes), now) sut.triggerRefresh() - wheneverBlocking { currencyService.fetchLatestRates() }.thenThrow(RuntimeException("API error")) - whenever(clock.now()).thenReturn(Clock.System.now()) sut.triggerRefresh() sut.currencyState.test(timeout = 2000.milliseconds) { @@ -199,3 +203,5 @@ class CurrencyRepoTest : BaseUnitTest() { } } } + +private class CurrencyRepoTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index dfd058c5fa..b0fef09b48 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -262,7 +262,7 @@ class LightningRepoTest : BaseUnitTest() { fun `getPayments should succeed when node is running`() = test { startNodeForTesting() val testPayments = listOf(mock()) - whenever(lightningService.payments).thenReturn(testPayments) + whenever(lightningService.listPayments()).thenReturn(testPayments) val result = sut.getPayments() assertTrue(result.isSuccess) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt new file mode 100644 index 0000000000..864ecd206e --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -0,0 +1,239 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.AddressType +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitReservationData +import to.bitkit.data.PrivatePaykitReservationStore +import to.bitkit.data.PrivatePaykitStoredAssignmentData +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.AddressDerivationInfo +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { + companion object { + private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val PRIVATE_ADDRESS = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" + } + + private val reservationStore = mock() + private val settingsStore = mock() + private val coreService = mock() + private val lightningRepo = mock() + private val reservationData = MutableStateFlow(PrivatePaykitReservationData()) + private val settingsData = MutableStateFlow(SettingsData()) + private val lightningState = MutableStateFlow( + LightningState(nodeLifecycleState = NodeLifecycleState.Running), + ) + + private lateinit var sut: PrivatePaykitAddressReservationRepo + + @Before + fun setUp() { + whenever(reservationStore.data).thenReturn(reservationData) + whenever { reservationStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + reservationData.value = transform(reservationData.value) + } + whenever(settingsStore.data).thenReturn(settingsData) + whenever(lightningRepo.lightningState).thenReturn(lightningState) + + sut = PrivatePaykitAddressReservationRepo( + ioDispatcher = testDispatcher, + reservationStore = reservationStore, + settingsStore = settingsStore, + coreService = coreService, + lightningRepo = lightningRepo, + ) + } + + @Test + fun `contactsWithUsedReservedAddresses treats positive ldk address balance as used`() = test { + reservationData.value = PrivatePaykitReservationData( + reservedReceiveIndexesByAddressType = mapOf("nativeSegwit" to setOf(1)), + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + ), + ) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + whenever(lightningRepo.getAddressBalance(PRIVATE_ADDRESS)).thenReturn(Result.success(100_000u)) + + val result = sut.contactsWithUsedReservedAddresses() + + assertEquals(listOf(CONTACT_KEY), result) + } + + @Test + fun `backupSnapshot keeps highest active and restored private indexes`() = test { + reservationData.value = PrivatePaykitReservationData( + reservedReceiveIndexesByAddressType = mapOf("nativeSegwit" to setOf(1, 4)), + restoredReservedReceiveIndexCeilingsByAddressType = mapOf( + "nativeSegwit" to 3, + "taproot" to 2, + ), + ) + + val result = sut.backupSnapshot().getOrThrow() + + assertEquals( + mapOf( + "nativeSegwit" to 4, + "taproot" to 2, + ), + result, + ) + } + + @Test + fun `restoreBackup preserves private index ceilings and clears assignments`() = test { + reservationData.value = PrivatePaykitReservationData( + reservedReceiveIndexesByAddressType = mapOf("nativeSegwit" to setOf(1)), + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + ), + ) + + sut.restoreBackup(mapOf("nativeSegwit" to 2)).getOrThrow() + + assertEquals( + PrivatePaykitReservationData( + restoredReservedReceiveIndexCeilingsByAddressType = mapOf("nativeSegwit" to 2), + ), + reservationData.value, + ) + } + + @Test + fun `nextReusableReceiveAddress skips restored private receive indexes`() = test { + reservationData.value = PrivatePaykitReservationData( + restoredReservedReceiveIndexCeilingsByAddressType = mapOf("nativeSegwit" to 2), + ) + whenever(lightningRepo.revealReceiveAddresses(2, AddressType.P2WPKH)).thenReturn(Result.success(Unit)) + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn( + Result.success(AddressDerivationInfo(address = "address3", index = 3)), + ) + + val result = sut.nextReusableReceiveAddress(AddressType.P2WPKH).getOrThrow() + + assertEquals("address3", result) + verify(lightningRepo).revealReceiveAddresses(2, AddressType.P2WPKH) + } + + @Test + fun `nextReusableReceiveAddress advances past high restored private receive ceiling`() = test { + reservationData.value = PrivatePaykitReservationData( + restoredReservedReceiveIndexCeilingsByAddressType = mapOf("nativeSegwit" to 505), + ) + whenever(lightningRepo.revealReceiveAddresses(505, AddressType.P2WPKH)).thenReturn(Result.success(Unit)) + whenever(lightningRepo.newAddressInfoForType(AddressType.P2WPKH)).thenReturn( + Result.success(AddressDerivationInfo(address = "address506", index = 506)), + ) + + val result = sut.nextReusableReceiveAddress(AddressType.P2WPKH).getOrThrow() + + assertEquals("address506", result) + verify(lightningRepo).revealReceiveAddresses(505, AddressType.P2WPKH) + } + + @Test + fun `clearContactAssignment removes private address attribution history`() = test { + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + ), + contactAssignmentHistory = mapOf( + CONTACT_KEY to listOf( + PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + ), + ), + ) + + sut.clearContactAssignment(CONTACT_KEY) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + } + + @Test + fun `contactPublicKeyForReservedAddress skips assignments for other address types`() = test { + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData( + addressType = "taproot", + receiveIndex = 1, + address = "", + ), + ), + ) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + verify(lightningRepo, never()).addressInfoForType(any(), any()) + } + + @Test + fun `clearContactAssignments removes stale private address attribution history`() = test { + val savedContactKey = "pubkyeytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + val savedPrivateAddress = "bcrt1qsavedweva9vextackckt6pjy0mmuc54gnn8peu" + reservationData.value = PrivatePaykitReservationData( + contactAssignments = mapOf( + CONTACT_KEY to PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + savedContactKey to PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 2, + address = savedPrivateAddress, + ), + ), + contactAssignmentHistory = mapOf( + CONTACT_KEY to listOf( + PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 1, + address = PRIVATE_ADDRESS, + ), + ), + savedContactKey to listOf( + PrivatePaykitStoredAssignmentData( + addressType = "nativeSegwit", + receiveIndex = 2, + address = savedPrivateAddress, + ), + ), + ), + ) + + sut.clearContactAssignments(excludingPublicKeys = listOf(savedContactKey)) + + assertNull(sut.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + assertEquals(savedContactKey, sut.contactPublicKeyForReservedAddress(savedPrivateAddress)) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt new file mode 100644 index 0000000000..f0918ccb65 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -0,0 +1,674 @@ +package to.bitkit.repositories + +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.PaykitFfiException +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 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.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.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.services.CoreService +import to.bitkit.services.PubkyService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +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 + +@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 LINK_SNAPSHOT = "link-snapshot" + private const val UPDATED_LINK_SNAPSHOT = "updated-link-snapshot" + private const val HANDSHAKE_SNAPSHOT = "handshake-snapshot" + private const val LOCAL_PAYLOAD_HASH = "local-payload-hash" + private const val PRIVATE_BOLT11 = "lnbcrt1private" + private const val PRIVATE_PAYMENT_HASH = "010203" + private const val PAYLOAD_LIMIT_BOLT11_LENGTH = 902 + private const val NOW_SECONDS = 1_700_000_000L + private const val TOMBSTONE_PAYLOAD = """{"value":""}""" + } + + private val pubkyService = mock() + private val keychain = mock() + private val cacheStore = mock() + private val settingsStore = mock() + private val addressReservationRepo = mock() + private val lightningRepo = mock() + private val walletRepo = mock() + private val publicPaykitRepo = mock() + private val coreService = mock() + private val clock = mock() + + private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) + private val settingsData = MutableStateFlow(SettingsData()) + private val lightningState = MutableStateFlow( + LightningState(nodeLifecycleState = NodeLifecycleState.Running), + ) + + private lateinit var sut: PrivatePaykitRepo + + @Before + fun setUp() { + cacheData.value = PrivatePaykitCacheData() + settingsData.value = SettingsData() + + whenever(cacheStore.data).thenReturn(cacheData) + whenever { cacheStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) + cacheData.value = transform(cacheData.value) + } + 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)) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)).thenReturn(null) + whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) + + sut = createSut() + } + + @After + fun tearDown() { + App.currentActivity = null + } + + @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", + ), + ), + ).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) + } + + @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()) + } + + @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 `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 `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(emptyList()) + 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(emptyList()) + 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(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 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(emptyList()) + 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 measures private endpoint map 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(emptyList()) + 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() + + verify(pubkyService).setPrivatePayments( + eq(LINK_ID), + argThat> { + any { + it.methodId == MethodId.Bolt11.rawValue && + it.endpointData == PublicPaykitRepo.serializePayload(bolt11) + } + }, + ) + } + + @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("noise state counter mismatch"), + ), + ) + + repeat(3) { + assertEquals(PublicPaykitPaymentResult.NoEndpoint, sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow()) + } + + assertStaleLinkRecoveryStarted() + } + + @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(emptyList()) + 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.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + assertNotNull(snapshot) + assertEquals(emptyMap(), snapshot.remoteEndpoints) + } + + @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, + ), + ), + ) + 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("noise state counter mismatch") } + whenever(pubkyService.getPrivatePayments(retryLinkId)).thenReturn( + listOf( + FfiPaymentEntry( + methodId = MethodId.P2wpkh.rawValue, + endpointData = PublicPaykitRepo.serializePayload("bcrt1qprivate"), + ), + ), + ) + 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() + + 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) + } + + @Test + fun `isDuplicatePaymentError detects wrapped duplicate payment messages`() { + val error = PrivatePaykitTestError("service queue failed", cause = AppError("Duplicate payment.")) + + assertTrue(PrivatePaykitRepo.isDuplicatePaymentError(error)) + } + + 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) + } + + private fun createSut() = PrivatePaykitRepo( + ioDispatcher = testDispatcher, + pubkyService = pubkyService, + keychain = keychain, + cacheStore = cacheStore, + settingsStore = settingsStore, + addressReservationRepo = addressReservationRepo, + lightningRepo = lightningRepo, + walletRepo = walletRepo, + publicPaykitRepo = publicPaykitRepo, + coreService = coreService, + 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 fun startForegroundWithSharingEnabled() { + settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + whenever(walletRepo.walletExists()).thenReturn(true) + App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } + } + + 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 lightningInvoice(bolt11: String, paymentHash: ByteArray) = LightningInvoice( + bolt11 = bolt11, + paymentHash = paymentHash, + amountSatoshis = 0uL, + timestampSeconds = 0u, + expirySeconds = 86_400u, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) + + private fun secretStateJson(): String = + """{"contacts":{"$CONTACT_KEY":{"linkSnapshotHex":"$LINK_SNAPSHOT","handshakeSnapshotHex":null}}}""" +} + +private class PrivatePaykitTestError( + message: String, + cause: Throwable? = null, +) : AppError(message, cause) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4140a80b63..4bb573c570 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -2,8 +2,6 @@ package to.bitkit.repositories import app.cash.turbine.test import com.synonym.bitkitcore.AddressType -import com.synonym.bitkitcore.GetAddressResponse -import com.synonym.bitkitcore.GetAddressesResponse import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -15,7 +13,6 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -27,6 +24,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.models.BalanceState +import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest @@ -48,6 +46,7 @@ class WalletRepoTest : BaseUnitTest() { private val preActivityMetadataRepo = mock() private val deriveBalanceStateUseCase = mock() private val wipeWalletUseCase = mock() + private val privatePaykitAddressReservationRepo = mock() private val transferRepo = mock() private val onchainService = mock() private val activityRepo = mock() @@ -87,13 +86,14 @@ class WalletRepoTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) .thenReturn(Result.success(SATS)) whenever(lightningRepo.canReceive()).thenReturn(false) + whenever { privatePaykitAddressReservationRepo.nextReusableReceiveAddress() } + .thenReturn(Result.success(ADDRESS_NEW)) + whenever { privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(any()) } + .thenReturn(false) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever { settingsStore.update(any()) }.thenReturn(Unit) whenever(deriveBalanceStateUseCase.invoke()).thenReturn(Result.success(BalanceState())) - whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") - whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null) - whenever(coreService.onchain).thenReturn(onchainService) whenever(preActivityMetadataRepo.addPreActivityMetadataTags(any(), any())).thenReturn(Result.success(Unit)) whenever(preActivityMetadataRepo.removePreActivityMetadataTags(any(), any())).thenReturn(Result.success(Unit)) @@ -102,24 +102,17 @@ class WalletRepoTest : BaseUnitTest() { whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(preActivityMetadataRepo.resetPreActivityMetadataTags(any())).thenReturn(Result.success(Unit)) whenever(preActivityMetadataRepo.deletePreActivityMetadata(any())).thenReturn(Result.success(Unit)) - val mockAddressForGetAddresses = mock { - on { address } doReturn "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" - on { path } doReturn "m/84'/0'/0'/0/0" - } - val mockGetAddressesResponse = mock { - on { addresses } doReturn listOf(mockAddressForGetAddresses) - } - whenever { - onchainService.deriveBitcoinAddresses( - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), + whenever { lightningRepo.addressInfosForType(any(), any(), any(), any()) } + .thenReturn( + Result.success( + listOf( + AddressDerivationInfo( + address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + index = 0, + ), + ), + ), ) - }.thenReturn(mockGetAddressesResponse) sut = createSut() } @@ -133,6 +126,7 @@ class WalletRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, deriveBalanceStateUseCase = deriveBalanceStateUseCase, wipeWalletUseCase = wipeWalletUseCase, + privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, transferRepo = transferRepo, activityRepo = activityRepo, ) @@ -225,24 +219,21 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should generate new address when current is empty`() = test { - whenever(lightningRepo.newAddress()).thenReturn(Result.success(ADDRESS_NEW)) - val result = sut.refreshBip21() assertTrue(result.isSuccess) - verify(lightningRepo).newAddress() + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() } @Test fun `refreshBip21 should generate new address when current has transactions`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) - whenever(lightningRepo.newAddress()).thenReturn(Result.success(ADDRESS_NEW)) whenever(coreService.isAddressUsed(any())).thenReturn(true) val result = sut.refreshBip21() assertTrue(result.isSuccess) - verify(lightningRepo).newAddress() + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() } @Test @@ -256,7 +247,7 @@ class WalletRepoTest : BaseUnitTest() { val result = sut.refreshBip21() assertTrue(result.isSuccess) - verify(lightningRepo, never()).newAddress() + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() } @Test @@ -584,7 +575,6 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) whenever(coreService.isAddressUsed(any())).thenReturn(true) - whenever(lightningRepo.newAddress()).thenReturn(Result.success(ADDRESS_NEW)) sut = createSut() sut.loadFromCache() @@ -597,7 +587,7 @@ class WalletRepoTest : BaseUnitTest() { ) ) - verify(lightningRepo).newAddress() + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() } @Test @@ -616,7 +606,7 @@ class WalletRepoTest : BaseUnitTest() { ) ) - verify(lightningRepo, never()).newAddress() + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() } @Test @@ -639,36 +629,22 @@ class WalletRepoTest : BaseUnitTest() { } @Test - fun `getAddresses should call deriveBitcoinAddresses with P2WPKH path by default`() = test { + fun `getAddresses should call ldk address infos with P2WPKH by default`() = test { val result = sut.getAddresses() assertTrue(result.isSuccess) assertEquals(1, result.getOrNull()?.size) - verify(onchainService).deriveBitcoinAddresses( - any(), - argThat { path -> path?.contains("m/84") == true }, - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) + assertTrue(result.getOrNull()?.firstOrNull()?.path?.startsWith("m/84'") == true) + verify(lightningRepo).addressInfosForType(AddressType.P2WPKH, isChange = false, startIndex = 0, count = 20) } @Test - fun `getAddresses should call deriveBitcoinAddresses with P2TR path when addressType is Taproot`() = test { + fun `getAddresses should call ldk address infos with P2TR when addressType is Taproot`() = test { val result = sut.getAddresses(addressType = AddressType.P2TR) assertTrue(result.isSuccess) assertEquals(1, result.getOrNull()?.size) - verify(onchainService).deriveBitcoinAddresses( - any(), - argThat { path -> path?.contains("m/86") == true }, - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) + assertTrue(result.getOrNull()?.firstOrNull()?.path?.startsWith("m/86'") == true) + verify(lightningRepo).addressInfosForType(AddressType.P2TR, isChange = false, startIndex = 0, count = 20) } } diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt new file mode 100644 index 0000000000..e77bf24ae2 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -0,0 +1,59 @@ +package to.bitkit.services + +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.Node +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsStore +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.createChannelDetails +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.LoggerLdk +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LightningServiceTest : BaseUnitTest() { + private val keychain = mock() + private val vssStoreIdProvider = mock() + private val settingsStore = mock() + private val loggerLdk = mock() + private val node = mock() + + private lateinit var sut: LightningService + + @Before + fun setUp() { + sut = LightningService( + bgDispatcher = testDispatcher, + keychain = keychain, + vssStoreIdProvider = vssStoreIdProvider, + settingsStore = settingsStore, + loggerLdk = loggerLdk, + ) + sut.node = node + } + + @Test + fun `canReceive returns false when channel is ready but not usable`() { + val readyButNotUsable = createChannelDetails().copy( + isChannelReady = true, + isUsable = false, + ) + whenever(node.listChannels()).thenReturn(listOf(readyButNotUsable)) + + assertFalse(sut.canReceive()) + } + + @Test + fun `canReceive returns true when channel is usable`() { + val usableChannel = createChannelDetails().copy( + isChannelReady = true, + isUsable = true, + ) + whenever(node.listChannels()).thenReturn(listOf(usableChannel)) + + assertTrue(sut.canReceive()) + } +} 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 24cc33e712..57cee13169 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 @@ -13,6 +13,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -28,6 +29,7 @@ class EditProfileViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() @Test fun `updateLinkUrl should update existing profile link`() = test { @@ -131,10 +133,14 @@ 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()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) return EditProfileViewModel( context = context, pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepo, ) } 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 new file mode 100644 index 0000000000..a717362a7e --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -0,0 +1,176 @@ +package to.bitkit.ui.screens.profile + +import android.content.Context +import app.cash.turbine.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PayContactsViewModelTest : BaseUnitTest() { + companion object { + private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + } + + private val context: Context = mock() + private val settingsStore: SettingsStore = mock() + private val publicPaykitRepo: PublicPaykitRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() + private val pubkyRepo: PubkyRepo = mock() + + private val settingsFlow = MutableStateFlow(SettingsData()) + private val contactsFlow = MutableStateFlow(listOf(createContact(CONTACT_KEY))) + + @Before + fun setUp() { + settingsFlow.value = SettingsData() + contactsFlow.value = listOf(createContact(CONTACT_KEY)) + + whenever(context.getString(any())).thenReturn("") + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(pubkyRepo.contacts).thenReturn(contactsFlow) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + Unit + } + whenever { publicPaykitRepo.syncPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + .thenReturn(Result.success(Unit)) + } + + @Test + fun `continueToProfile enables sharing and prepares private contacts`() = test { + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + assertEquals(PayContactsEffect.Continue, awaitItem()) + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo).setContactSharingCleanupPending(false) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY)) + verify(privatePaykitRepo, never()).disableSharingAndClearLocalState(any>()) + } + + @Test + fun `continueToProfile proceeds when private contact preparation fails`() = test { + whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + .thenReturn(Result.failure(PayContactsTestAppError("private setup failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + assertEquals(PayContactsEffect.Continue, awaitItem()) + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY)) + } + + @Test + fun `continueToProfile clears cleanup marker after disabling succeeds`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(false) + sut.continueToProfile() + advanceUntilIdle() + + assertEquals(PayContactsEffect.Continue, awaitItem()) + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo).disableSharingAndClearLocalState(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).setContactSharingCleanupPending(false) + assertFalse(sut.uiState.value.isLoading) + } + + @Test + fun `continueToProfile marks cleanup pending when disabling fails`() = test { + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(false) + sut.continueToProfile() + advanceUntilIdle() + + expectNoEvents() + } + + assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(sut.uiState.value.isLoading) + assertFalse(sut.uiState.value.isPaymentSharingEnabled) + verify(privatePaykitRepo).setContactSharingCleanupPending(true) + } + + private fun createSut() = PayContactsViewModel( + context = context, + settingsStore = settingsStore, + publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, + pubkyRepo = pubkyRepo, + ) +} + +private fun createContact(publicKey: String) = PubkyProfile( + publicKey = publicKey, + name = "Alice", + bio = "", + imageUrl = null, + links = emptyList(), + tags = persistentListOf(), + status = null, +) + +private class PayContactsTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt index a8beb379a4..76b3f2f1d6 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -19,6 +19,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus @@ -30,6 +31,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { private val settingsStore: SettingsStore = mock() private val lightningRepo: LightningRepo = mock() private val walletRepo: WalletRepo = mock() + private val privatePaykitRepo: PrivatePaykitRepo = mock() private lateinit var sut: AddressTypePreferenceViewModel @@ -70,6 +72,8 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { ) ) ) + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any()) } + .thenReturn(Result.success(Unit)) } private fun createSut(): AddressTypePreferenceViewModel = @@ -79,6 +83,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { settingsStore = settingsStore, lightningRepo = lightningRepo, walletRepo = walletRepo, + privatePaykitRepo = privatePaykitRepo, ) @Test diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index d13ac24a5b..b6ad7472ff 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -4,6 +4,7 @@ import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -17,10 +18,13 @@ import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.PrivatePaykitAddressReservationRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest +import javax.inject.Provider import kotlin.test.assertTrue class WipeWalletUseCaseTest : BaseUnitTest() { @@ -36,8 +40,11 @@ class WipeWalletUseCaseTest : BaseUnitTest() { private val activityRepo = mock() private val lightningRepo = mock() private val pubkyRepo = mock() + private val privatePaykitRepo = mock() + private val privatePaykitAddressReservationRepo = mock() private val firebaseMessaging = mock() private val migrationService = mock() + private val privatePaykitRepoProvider = Provider { privatePaykitRepo } private lateinit var sut: WipeWalletUseCase @@ -48,6 +55,9 @@ 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.closeAndClear() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitAddressReservationRepo.clear() }.thenReturn(Unit) onWipeCalled = false onSetWalletExistsStateCalled = false @@ -63,6 +73,8 @@ class WipeWalletUseCaseTest : BaseUnitTest() { activityRepo = activityRepo, lightningRepo = lightningRepo, pubkyRepo = pubkyRepo, + privatePaykitRepo = privatePaykitRepoProvider, + privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, firebaseMessaging = firebaseMessaging, migrationService = migrationService, ) @@ -88,9 +100,14 @@ class WipeWalletUseCaseTest : BaseUnitTest() { activityRepo, lightningRepo, pubkyRepo, + privatePaykitRepo, + privatePaykitAddressReservationRepo, ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() + inOrder.verify(privatePaykitRepo).removePublishedEndpointsBestEffort(any()) + inOrder.verify(privatePaykitRepo).closeAndClear() + inOrder.verify(privatePaykitAddressReservationRepo).clear() inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(pubkyRepo).wipeLocalState() inOrder.verify(keychain).wipe() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 99bf1e6479..b866aa641d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -28,6 +28,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo @@ -42,12 +43,14 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState import to.bitkit.repositories.WidgetsRepo +import to.bitkit.services.ActivityService import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService @@ -86,9 +89,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val transferRepo = mock() private val migrationService = mock() private val coreService = mock() + private val activityService = mock() private val keychain = mock() private val pubkyRepo = mock() private val publicPaykitRepo = mock() + private val privatePaykitRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -96,6 +101,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val walletState = MutableStateFlow(WalletState()) private val nodeEvents = MutableSharedFlow() + private val pubkyPublicKey = MutableStateFlow(null) + private val pubkyContacts = MutableStateFlow>(emptyList()) + private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() @@ -112,6 +120,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) whenever(walletRepo.walletExists()).thenReturn(true) @@ -127,8 +136,33 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit) whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) - whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) + whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) + whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) + whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) + whenever { privatePaykitRepo.prepareSavedContacts(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.reconcileReservedReceiveIndexes() } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.retryPendingEndpointRemoval(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.removeSavedContact(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.reconcileReceivedPayments() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.handleOnchainActivity(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(any()) } + .thenReturn(null) + whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } + .thenReturn(null) + whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(any(), any()) } + .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) .thenReturn(Result.failure(Exception("not mocked"))) whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } @@ -162,6 +196,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { migrationService = migrationService, coreService = coreService, publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), @@ -526,6 +561,48 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(0L, sut.sendUiState.value.lastLightningFee) } + @Test + fun `private Paykit waits for contacts load before pruning`() = test { + clearInvocations(privatePaykitRepo) + + pubkyPublicKey.value = testPublicKey + advanceUntilIdle() + + verify(privatePaykitRepo, never()).prepareSavedContacts(any>()) + verify(privatePaykitRepo, never()).pruneUnsavedContactState(any>()) + + pubkyContactsLoadVersion.value = 1L + advanceUntilIdle() + + verify(privatePaykitRepo).prepareSavedContacts(any>()) + verify(privatePaykitRepo).pruneUnsavedContactState(any>()) + } + + @Test + fun `private Paykit removes stale contact without duplicate load version cleanup`() = test { + val contact = PubkyProfile( + publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo", + name = "Bob", + bio = "", + imageUrl = null, + links = emptyList(), + status = null, + ) + pubkyPublicKey.value = testPublicKey + pubkyContacts.value = listOf(contact) + pubkyContactsLoadVersion.value = 1L + advanceUntilIdle() + clearInvocations(privatePaykitRepo) + + pubkyContacts.value = emptyList() + pubkyContactsLoadVersion.value = 2L + advanceUntilIdle() + + verify(privatePaykitRepo).removeSavedContact(contact.publicKey) + verify(privatePaykitRepo).prepareSavedContacts(emptySet()) + verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) + } + private fun enableQuickPay(thresholdSats: ULong) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) diff --git a/changelog.d/next/private-paykit.added.md b/changelog.d/next/private-paykit.added.md new file mode 100644 index 0000000000..2f4fb35651 --- /dev/null +++ b/changelog.d/next/private-paykit.added.md @@ -0,0 +1 @@ +Added private Paykit contact payments with dedicated contact endpoints, rotation, cleanup, and restore-safe address reservations. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 58fdeeb050..394418546c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.37" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.39" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From 043c42ba7025b49528916fb04dbe8f8ea3267039 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 14 May 2026 13:59:03 -0500 Subject: [PATCH 2/5] refactor: split private paykit repo --- .../PrivatePaykitErrorClassifier.kt | 71 ++ .../repositories/PrivatePaykitModels.kt | 160 +++++ .../repositories/PrivatePaykitPayloads.kt | 61 ++ .../PrivatePaykitRecoveryStore.kt | 226 +++++++ .../bitkit/repositories/PrivatePaykitRepo.kt | 604 ++---------------- .../repositories/PrivatePaykitStateStore.kt | 55 ++ 6 files changed, 627 insertions(+), 550 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt new file mode 100644 index 0000000000..c3bd69d60f --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt @@ -0,0 +1,71 @@ +package to.bitkit.repositories + +import com.synonym.paykit.PaykitFfiException +import org.lightningdevkit.ldknode.NodeException + +internal object PrivatePaykitErrorClassifier { + fun isDuplicatePaymentError(error: Throwable): Boolean { + val errors = error.causes() + if (errors.any { it is NodeException.DuplicatePayment }) return true + + val reason = errors.mapNotNull { it.message } + .joinToString(separator = " ") + .lowercase() + 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", "noise state", "counter", "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 new file mode 100644 index 0000000000..2e07f3d40c --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -0,0 +1,160 @@ +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) { + data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") + data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") + 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( + 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, + ) = PrivatePaykitCacheData( + contacts = contacts.mapNotNull { (publicKey, contactState) -> + (publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState } + }.toMap(), + cleanupPending = cleanupPending, + deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys, + ) +} + +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 linkFailureCount: Int = 0, +) { + 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, + linkFailureCount = cache.linkFailureCount, + ) + + 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() || + localInvoice != null || + receivedInvoicePaymentHashes.isNotEmpty() || + lastLocalPayloadHash != null || + linkCompletedAt != null || + handshakeUpdatedAt != null || + recoveryStartedAt != null || + mainRecoveryAttemptId != null || + responderRecoveryAttemptId != null || + lastCompletedRecoveryAttemptId != null || + linkFailureCount != 0 + + 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, + linkFailureCount = linkFailureCount, + ) +} + +@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, +) + +internal data class StoredInvoice( + val bolt11: String, + 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 new file mode 100644 index 0000000000..43ce5845b9 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitPayloads.kt @@ -0,0 +1,61 @@ +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 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 } + return noisePayloadJson.encodeToString(payload).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES + } +} + +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 new file mode 100644 index 0000000000..0e7f5fe9b7 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRecoveryStore.kt @@ -0,0 +1,226 @@ +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 + } + + 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 ca861593c9..51e42d6288 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2,7 +2,6 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.paykit.FfiPaymentEntry -import com.synonym.paykit.PaykitFfiException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -18,32 +17,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import to.bitkit.App -import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore -import to.bitkit.data.PrivatePaykitContactCacheData -import to.bitkit.data.PrivatePaykitStoredInvoiceData -import to.bitkit.data.PrivatePaykitStoredPaymentEntryData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher -import to.bitkit.di.json import to.bitkit.ext.toHex import to.bitkit.models.PrivatePaykitContactLinkBackupV1 import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PubkyService -import to.bitkit.utils.AppError import to.bitkit.utils.Logger -import java.security.MessageDigest import java.util.UUID import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject @@ -55,13 +42,6 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) { - data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") - data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") - data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed") - class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause) -} - @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Singleton @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @@ -80,23 +60,15 @@ class PrivatePaykitRepo @Inject constructor( ) { companion object { private const val TAG = "PrivatePaykitRepo" - private const val MAX_NOISE_PAYLOAD_BYTES = 1000 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 PRIVATE_ENDPOINT_REMOVAL_PAYLOAD = """{"value":""}""" 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 COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS = 5 * 60L private const val FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS = 8L - 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 private const val PENDING_PUBLICATION_RETRY_ATTEMPTS = 60 - private val noisePayloadJson = Json(json) { - prettyPrint = false - } private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes private val pendingPublicationRetryDelay = 5.seconds @@ -107,18 +79,12 @@ class PrivatePaykitRepo @Inject constructor( return own > remote } - fun isDuplicatePaymentError(error: Throwable): Boolean { - val errors = generateSequence(error) { it.cause }.toList() - if (errors.any { it is NodeException.DuplicatePayment }) return true - - val reason = errors.mapNotNull { it.message } - .joinToString(separator = " ") - .lowercase() - return "duplicate payment" in reason || "duplicatepayment" in reason - } + fun isDuplicatePaymentError(error: Throwable): Boolean = + PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) } - private var state: PrivatePaykitState? = null + 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() @@ -241,7 +207,7 @@ class PrivatePaykitRepo @Inject constructor( closeActiveHandles() activeHandlesByContact.clear() knownSavedContactKeys.clear() - state = PrivatePaykitState() + stateStore.replaceState(PrivatePaykitState()) keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) cacheStore.reset() addressReservationRepo.clearContactAssignments(excludingPublicKeys = emptySet()) @@ -302,7 +268,7 @@ class PrivatePaykitRepo @Inject constructor( it, context = TAG, ) - if (hadCachedPrivateEndpoint && !shouldCountAsStaleLinkFailure(it)) { + if (hadCachedPrivateEndpoint && !PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) { return@runCatching true } return@runCatching publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() @@ -464,7 +430,7 @@ class PrivatePaykitRepo @Inject constructor( knownSavedContactKeys.clear() if (backup == null) { - state = PrivatePaykitState() + stateStore.replaceState(PrivatePaykitState()) persistState() notifyBackupStateChanged() return@runCatching @@ -485,7 +451,9 @@ class PrivatePaykitRepo @Inject constructor( normalizedKey to ContactState( linkSnapshotHex = linkSnapshotHex, handshakeSnapshotHex = handshakeSnapshotHex, - remoteEndpoints = storedPaymentEntries(contactBackup.remoteEndpoints), + remoteEndpoints = PrivatePaykitPayloads.storedPaymentEntries( + contactBackup.remoteEndpoints, + ), linkCompletedAt = contactBackup.linkCompletedAt, handshakeUpdatedAt = contactBackup.handshakeUpdatedAt, recoveryStartedAt = contactBackup.recoveryStartedAt, @@ -494,7 +462,7 @@ class PrivatePaykitRepo @Inject constructor( ) }.toMap() - state = PrivatePaykitState(contacts = contacts.toMutableMap()) + stateStore.replaceState(PrivatePaykitState(contacts = contacts.toMutableMap())) } } persistState() @@ -520,7 +488,7 @@ class PrivatePaykitRepo @Inject constructor( } val fetchedCount = fetchRemoteEndpoints(publicKey, linkId, generation).getOrElse { - if (shouldCountAsStaleLinkFailure(it)) throw it + if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) throw it Logger.warn( "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", it, @@ -637,7 +605,7 @@ class PrivatePaykitRepo @Inject constructor( it }, onFailure = { - val shouldRetry = shouldRetryLinkEstablishmentFailure(it) + val shouldRetry = PrivatePaykitErrorClassifier.shouldRetryLinkEstablishmentFailure(it) if (scheduleRetries && shouldRetry) schedulePendingPublicationRetry(publicKey) Logger.debug( if (shouldRetry) { @@ -695,7 +663,7 @@ class PrivatePaykitRepo @Inject constructor( it, context = TAG, ) - if (shouldCountAsStaleLinkFailure(it)) null else 0 + if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) null else 0 }, ) @@ -790,8 +758,16 @@ class PrivatePaykitRepo @Inject constructor( val endpoints = buildLocalEndpoints(publicKey).getOrThrow() ensureCurrentGeneration(generation) - val entries = entriesWithinNoiseLimit(endpoints, publicKey) - val payloadHash = localPayloadHash(entries) + val payloadSelection = PrivatePaykitPayloads.entriesWithinNoiseLimit(endpoints) + if (payloadSelection.droppedLightning) { + ensureState().contacts[publicKey]?.localInvoice = null + Logger.warn( + "Published private Paykit on-chain only for '${redacted(publicKey)}'", + 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 @@ -888,7 +864,7 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runCatching { readRemoteEndpoints(publicKey, linkId, generation).getOrElse { error -> - if (!shouldCountAsStaleLinkFailure(error)) throw error + if (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) throw error val restoredLinkId = restoreLinkHandleForReadRetry(publicKey, generation).getOrNull() ?: throw error @@ -994,7 +970,7 @@ class PrivatePaykitRepo @Inject constructor( val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } activeHandlesByContact[normalizedKey]?.linkId?.let { linkId -> - val remoteRecoveryMarker = freshRecoveryMarker( + val remoteRecoveryMarker = recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_INIT), @@ -1028,7 +1004,7 @@ class PrivatePaykitRepo @Inject constructor( persistState(markWalletBackup = true) }.getOrNull() if (restoredLinkId != null) { - val remoteRecoveryMarker = freshRecoveryMarker( + val remoteRecoveryMarker = recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_INIT), @@ -1047,7 +1023,7 @@ class PrivatePaykitRepo @Inject constructor( } val isRecovering = shouldStartRecoveryHandshake(normalizedKey) - val fetchedRemoteRecoveryInitMarker = freshRecoveryMarker( + val fetchedRemoteRecoveryInitMarker = recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_INIT), @@ -1055,7 +1031,7 @@ class PrivatePaykitRepo @Inject constructor( val remoteRecoveryInitMarker = fetchedRemoteRecoveryInitMarker ?.takeUnless { isCompletedRecoveryMarker(it, normalizedKey) } val remoteRecoveryFinalForResponder = contactState.responderRecoveryAttemptId?.let { - freshRecoveryMarker( + recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_FINAL), @@ -1066,7 +1042,7 @@ class PrivatePaykitRepo @Inject constructor( val initialMainRecoveryAttemptId = contactState.mainRecoveryAttemptId val localMainRecoveryMarker = initialMainRecoveryAttemptId?.let { - freshRecoveryMarker( + recoveryStore.freshRecoveryMarker( from = ownPublicKey, to = normalizedKey, stages = setOf(RECOVERY_MARKER_STAGE_INIT, RECOVERY_MARKER_STAGE_FINAL), @@ -1089,7 +1065,7 @@ class PrivatePaykitRepo @Inject constructor( if (shouldAcceptRemoteRecovery && remoteRecoveryMarker != null) { val isNewResponderAttempt = contactState.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId if (isNewResponderAttempt) { - if (!purgePrivatePaymentOutbox(normalizedKey, "recovery responder")) return null + if (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery responder")) return null ensureCurrentGeneration(generation) activeHandlesByContact[normalizedKey]?.handshakeId?.let { runCatching { pubkyService.dropEncryptedLinkHandshake(it) } @@ -1103,7 +1079,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) } - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_RESPONSE, @@ -1114,7 +1090,7 @@ class PrivatePaykitRepo @Inject constructor( val shouldInitiateRecovery = isRecovering && !shouldAcceptRemoteRecovery if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId == null) { - if (!purgePrivatePaymentOutbox(normalizedKey, "recovery initiator")) return null + if (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery initiator")) return null ensureCurrentGeneration(generation) activeHandlesByContact[normalizedKey]?.handshakeId?.let { runCatching { pubkyService.dropEncryptedLinkHandshake(it) } @@ -1129,7 +1105,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.lastLocalPayloadHash = null contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_INIT, @@ -1144,7 +1120,7 @@ class PrivatePaykitRepo @Inject constructor( contactState.mainRecoveryAttemptId != null && localMainRecoveryMarker == null ) { - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_INIT, @@ -1164,14 +1140,14 @@ class PrivatePaykitRepo @Inject constructor( contactState.handshakeSnapshotHex != null ) { val attemptId = checkNotNull(contactState.mainRecoveryAttemptId) - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_INIT, attemptId = attemptId, createdAt = clock.now().epochSeconds, ) - val hasPeerProgress = freshRecoveryMarker( + val hasPeerProgress = recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_RESPONSE, RECOVERY_MARKER_STAGE_FINAL), @@ -1186,14 +1162,14 @@ class PrivatePaykitRepo @Inject constructor( contactState.handshakeSnapshotHex != null ) { val attemptId = checkNotNull(contactState.responderRecoveryAttemptId) - val hasPeerFinal = freshRecoveryMarker( + val hasPeerFinal = recoveryStore.freshRecoveryMarker( from = normalizedKey, to = ownPublicKey, stages = setOf(RECOVERY_MARKER_STAGE_FINAL), attemptId = attemptId, ) != null if (!hasPeerFinal) { - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_RESPONSE, @@ -1249,7 +1225,7 @@ class PrivatePaykitRepo @Inject constructor( repeat(maxAdvanceSteps) { val progress = runCatching { pubkyService.advanceHandshake(checkNotNull(handshakeId)) } .getOrElse { - if (isEncryptedHandshakePendingError(it)) { + if (PrivatePaykitErrorClassifier.isEncryptedHandshakePendingError(it)) { val snapshot = pubkyService.serializeEncryptedLinkHandshake(checkNotNull(handshakeId)) ensureCurrentGeneration(generation) contactState.handshakeSnapshotHex = snapshot @@ -1258,7 +1234,7 @@ class PrivatePaykitRepo @Inject constructor( persistState(markWalletBackup = true) return null } - if (isEncryptedHandshakeStateFailure(it)) { + if (PrivatePaykitErrorClassifier.isEncryptedHandshakeStateFailure(it)) { ensureCurrentGeneration(generation) activeHandlesByContact[normalizedKey] = ContactPaykitHandles() contactState.handshakeSnapshotHex = null @@ -1282,7 +1258,7 @@ class PrivatePaykitRepo @Inject constructor( generation = generation, ).getOrThrow() if (isRecoveryHandshake && attemptId != null) { - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_FINAL, @@ -1304,7 +1280,7 @@ class PrivatePaykitRepo @Inject constructor( if (isRecoveryHandshake) { val createdAt = clock.now().epochSeconds if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId != null) { - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_INIT, @@ -1312,7 +1288,7 @@ class PrivatePaykitRepo @Inject constructor( createdAt = createdAt, ) } else if (shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { - publishRecoveryMarker( + recoveryStore.publishRecoveryMarker( from = ownPublicKey, to = normalizedKey, stage = RECOVERY_MARKER_STAGE_RESPONSE, @@ -1379,8 +1355,8 @@ class PrivatePaykitRepo @Inject constructor( } if (linkId == null) return@withLock - val entries = privateEndpointRemovalEntries() - validateNoisePayload(entries) + val entries = PrivatePaykitPayloads.privateEndpointRemovalEntries() + PrivatePaykitPayloads.validateNoisePayload(entries) pubkyService.setPrivatePayments( linkId, entries.map { FfiPaymentEntry(it.methodId, it.endpointData) }, @@ -1396,7 +1372,7 @@ class PrivatePaykitRepo @Inject constructor( ).getOrThrow() pubkyService.currentPublicKey() ?.let { PubkyPublicKeyFormat.normalized(it) } - ?.let { clearRecoveryMarker(from = it, to = publicKey) } + ?.let { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } } } Unit @@ -1422,7 +1398,7 @@ class PrivatePaykitRepo @Inject constructor( cancelPendingPublicationRetry(publicKey) pubkyService.currentPublicKey() ?.let { PubkyPublicKeyFormat.normalized(it) } - ?.let { clearRecoveryMarker(from = it, to = publicKey) } + ?.let { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } activeHandlesByContact[publicKey]?.handshakeId?.let { runCatching { pubkyService.dropEncryptedLinkHandshake(it) } @@ -1591,7 +1567,7 @@ class PrivatePaykitRepo @Inject constructor( } private fun shouldRequirePrivateEndpointRemoval(publicKey: String): Boolean { - val contactState = state?.contacts?.get(publicKey) ?: return false + val contactState = stateStore.currentState()?.contacts?.get(publicKey) ?: return false return contactState.linkSnapshotHex != null || contactState.lastLocalPayloadHash != null || contactState.localInvoice != null || @@ -1660,258 +1636,14 @@ class PrivatePaykitRepo @Inject constructor( } private fun isCompletedRecoveryMarker(marker: RecoveryMarker, publicKey: String): Boolean = - state?.contacts?.get(publicKey)?.lastCompletedRecoveryAttemptId == marker.attemptId + stateStore.currentState()?.contacts?.get(publicKey)?.lastCompletedRecoveryAttemptId == marker.attemptId private fun shouldReplaceUsableLink(marker: RecoveryMarker, publicKey: String): Boolean { if (isCompletedRecoveryMarker(marker, publicKey)) return false - val linkCompletedAt = state?.contacts?.get(publicKey)?.linkCompletedAt ?: return true + val linkCompletedAt = stateStore.currentState()?.contacts?.get(publicKey)?.linkCompletedAt ?: return true return marker.createdAt > linkCompletedAt + COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS } - @Suppress("ReturnCount") - private 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 contactKey = listOf(from, to) - .mapNotNull { normalizedPublicKey(it) } - .firstOrNull { ensureState().contacts[it] != null } - val linkCompletedAt = contactKey?.let { ensureState().contacts[it]?.linkCompletedAt } ?: 0L - if (marker.createdAt <= linkCompletedAt) return null - if (attemptId != null && marker.attemptId != attemptId) return null - return marker - } - - private 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, - ) - } - } - - private 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") - private suspend fun purgePrivatePaymentOutbox(publicKey: String, reason: String): Boolean { - val otherContactCount = ensureState().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 - } - - 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 { entry -> - if (deletedSoFar + deletedCount >= PRIVATE_STORAGE_PURGE_MAX_ENTRIES) { - didHitLimit = true - return@forEach - } - val path = privateStoragePath(entry) ?: 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 { - if (!isMissingPrivateStorageError(it)) 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 String.withTrailingSlash(): String = if (endsWith("/")) this else "$this/" - - private fun isMissingPrivateStorageError(error: Throwable): Boolean { - val reason = error.message.orEmpty().lowercase() - return "404" in reason && "not found" in reason - } - - 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 suspend fun entriesWithinNoiseLimit( - endpoints: List, - publicKey: String, - ): List { - val entries = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } - if (isNoisePayloadWithinLimit(entries)) return entries - - val onchainOnlyEntries = entries.filter { it.methodId != MethodId.Bolt11.rawValue } - if (onchainOnlyEntries.size < entries.size && onchainOnlyEntries.isNotEmpty()) { - if (isNoisePayloadWithinLimit(onchainOnlyEntries)) { - ensureState().contacts[publicKey]?.localInvoice = null - Logger.warn( - "Published private Paykit on-chain only for '${redacted(publicKey)}'", - context = TAG, - ) - return onchainOnlyEntries - } - } - - throw PrivatePaykitError.PayloadTooLarge - } - - private fun privateEndpointRemovalEntries(): List = - MethodId.entries - .filter { it.isBitkitManaged } - .map { StoredPaymentEntry(it.rawValue, PRIVATE_ENDPOINT_REMOVAL_PAYLOAD) } - - private fun validateNoisePayload(entries: List) { - if (!isNoisePayloadWithinLimit(entries)) throw PrivatePaykitError.PayloadTooLarge - } - - private fun isNoisePayloadWithinLimit(entries: List): Boolean { - val payload = entries.associate { it.methodId to it.endpointData } - return noisePayloadJson.encodeToString(payload).encodeToByteArray().size <= MAX_NOISE_PAYLOAD_BYTES - } - - private 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) } - } - private suspend fun settledPrivateInvoicePaymentHashes(): List { val settled = receivedSettledPaymentHashes() return ensureState().contacts.values.mapNotNull { it.localInvoice?.paymentHash?.takeIf(settled::contains) } @@ -1957,7 +1689,7 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun recordLinkFailure(publicKey: String, error: Throwable, generation: Long? = null) { if (generation != null && stateGeneration.get() != generation) return - if (!shouldCountAsStaleLinkFailure(error)) return + if (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) return val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } contactState.linkFailureCount += 1 if (contactState.linkFailureCount < STALE_LINK_FAILURE_THRESHOLD) { @@ -1986,61 +1718,6 @@ class PrivatePaykitRepo @Inject constructor( persistState() } - private 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) } - } - - private fun shouldRetryLinkEstablishmentFailure(error: Throwable): Boolean = - error.causes().none { - it is PrivatePaykitError.PrivateUnavailable || it is PrivatePaykitError.StaleLinkState - } - - 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", "noise state", "counter", "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 } - } - - private 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 } - } - - private fun isEncryptedHandshakePendingError(error: Throwable): Boolean { - val reason = error.message.orEmpty().lowercase() - return "transition_transport failed" in reason && "ishandshake" in reason - } - private suspend fun validatedSnapshot( snapshotHex: String?, publicKey: String, @@ -2090,186 +1767,13 @@ class PrivatePaykitRepo @Inject constructor( private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) - private fun storedPaymentEntries(endpoints: Map): List = - endpoints.toSortedMap().map { StoredPaymentEntry(it.key, it.value) } - - private suspend fun ensureState(): PrivatePaykitState { - state?.let { return it } - val secretState = runCatching { - keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) - ?.let { json.decodeFromString(it) } - }.getOrNull() ?: PrivatePaykitSecretState() - val cacheState = cacheStore.data.first() - - return PrivatePaykitState(secretState, cacheState).also { state = it } - } + private suspend fun ensureState(): PrivatePaykitState = stateStore.ensureState() private suspend fun persistState(markWalletBackup: Boolean = false) { - 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 = stored.cleanupPending, - deletedContactCleanupPendingPublicKeys = stored.deletedContactCleanupPendingPublicKeys, - ) - } - if (markWalletBackup) notifyBackupStateChanged() - }.getOrElse { throw PrivatePaykitError.StatePersistenceFailed(it) } + stateStore.persistState(markWalletBackup, ::notifyBackupStateChanged) } private fun notifyBackupStateChanged() { _backupStateVersion.update { it + 1 } } } - -private data class ContactPaykitHandles( - val linkId: String? = null, - val handshakeId: String? = null, -) - -private data class PrivatePaykitState( - val contacts: MutableMap = mutableMapOf(), -) { - constructor(secretState: PrivatePaykitSecretState, 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, - ) = PrivatePaykitCacheData( - contacts = contacts.mapNotNull { (publicKey, contactState) -> - (publicKey to contactState.cacheState()).takeIf { contactState.hasCacheState } - }.toMap(), - cleanupPending = cleanupPending, - deletedContactCleanupPendingPublicKeys = deletedContactCleanupPendingPublicKeys, - ) -} - -private 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 linkFailureCount: Int = 0, -) { - 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, - linkFailureCount = cache.linkFailureCount, - ) - - 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() || - localInvoice != null || - receivedInvoicePaymentHashes.isNotEmpty() || - lastLocalPayloadHash != null || - linkCompletedAt != null || - handshakeUpdatedAt != null || - recoveryStartedAt != null || - mainRecoveryAttemptId != null || - responderRecoveryAttemptId != null || - lastCompletedRecoveryAttemptId != null || - linkFailureCount != 0 - - 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, - linkFailureCount = linkFailureCount, - ) -} - -@Serializable -private data class PrivatePaykitSecretState( - val contacts: Map = emptyMap(), -) - -@Serializable -private data class ContactSecretState( - val linkSnapshotHex: String? = null, - val handshakeSnapshotHex: String? = null, -) { - val hasSecretState: Boolean - get() = linkSnapshotHex != null || handshakeSnapshotHex != null -} - -private data class StoredPaymentEntry( - val methodId: String, - val endpointData: String, -) - -private data class StoredInvoice( - val bolt11: String, - val paymentHash: String, - val expiresAt: Long, -) - -private data class PrivateStoragePurgeResult( - val deletedCount: Int, - val didHitLimit: Boolean, - val didFail: Boolean, -) - -@Serializable -private 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/PrivatePaykitStateStore.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt new file mode 100644 index 0000000000..586f8e0cad --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt @@ -0,0 +1,55 @@ +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 + +internal class PrivatePaykitStateStore( + private val keychain: Keychain, + private val cacheStore: PrivatePaykitCacheStore, +) { + private var state: PrivatePaykitState? = null + + fun currentState(): PrivatePaykitState? = state + + fun replaceState(newState: PrivatePaykitState) { + state = newState + } + + suspend fun ensureState(): PrivatePaykitState { + state?.let { return it } + val secretState = runCatching { + keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) + ?.let { json.decodeFromString(it) } + }.getOrNull() ?: PrivatePaykitSecretState() + val cacheState = cacheStore.data.first() + + return PrivatePaykitState(secretState, cacheState).also { state = it } + } + + suspend fun persistState( + markWalletBackup: Boolean, + notifyBackupStateChanged: () -> Unit, + ) { + 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 = stored.cleanupPending, + deletedContactCleanupPendingPublicKeys = stored.deletedContactCleanupPendingPublicKeys, + ) + } + if (markWalletBackup) notifyBackupStateChanged() + }.getOrElse { throw PrivatePaykitError.StatePersistenceFailed(it) } + } +} From 040e86f4624ffa8d541184d02017f041d42ad293 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 14 May 2026 22:36:42 -0500 Subject: [PATCH 3/5] fix: harden private paykit payments --- .../java/to/bitkit/repositories/BackupRepo.kt | 7 +- .../PrivatePaykitContactResolver.kt | 35 +++ .../repositories/PrivatePaykitModels.kt | 1 + .../bitkit/repositories/PrivatePaykitRepo.kt | 207 +++++++++-------- .../repositories/PrivatePaykitStateStore.kt | 29 ++- .../java/to/bitkit/repositories/WalletRepo.kt | 13 +- .../java/to/bitkit/services/CoreService.kt | 41 ++-- .../screens/contacts/ContactDetailScreen.kt | 7 +- .../contacts/ContactDetailViewModel.kt | 6 +- .../screens/profile/PayContactsViewModel.kt | 32 +-- .../AddressTypePreferenceViewModel.kt | 15 -- .../settings/advanced/AddressViewerScreen.kt | 103 ++++++--- .../advanced/AddressViewerViewModel.kt | 101 ++++++--- .../java/to/bitkit/viewmodels/AppViewModel.kt | 21 +- app/src/main/res/values/strings.xml | 1 + ...PrivatePaykitAddressReservationRepoTest.kt | 13 ++ .../PrivatePaykitContactResolverTest.kt | 90 ++++++++ .../repositories/PrivatePaykitRepoTest.kt | 119 +++++++++- .../to/bitkit/repositories/WalletRepoTest.kt | 36 +++ .../profile/PayContactsViewModelTest.kt | 31 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 208 +++++++++++++++++- .../{private-paykit.added.md => 936.added.md} | 0 22 files changed, 866 insertions(+), 250 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt rename changelog.d/next/{private-paykit.added.md => 936.added.md} (100%) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2629275559..081723aecb 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -540,12 +540,12 @@ class BackupRepo @Inject constructor( .onFailure { Logger.warn("Failed to snapshot private Paykit reservations", it, context = TAG) } - .getOrDefault(null) + .getOrThrow() val privateLinks = privatePaykitRepo.get().backupSnapshot() .onFailure { Logger.warn("Failed to snapshot private Paykit contact links", it, context = TAG) } - .getOrDefault(null) + .getOrThrow() val payload = WalletBackupV1( createdAt = currentTimeMillis(), @@ -618,6 +618,9 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) + if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) { + cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } + } privatePaykitAddressReservationRepo.get() .restoreBackup(parsed.privatePaykitHighestReservedReceiveIndexByAddressType) .onFailure { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt new file mode 100644 index 0000000000..d72cab8dc4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt @@ -0,0 +1,35 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.di.IoDispatcher +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class PrivatePaykitContactResolver @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val cacheStore: PrivatePaykitCacheStore, + private val addressReservationRepo: Provider, +) { + suspend fun contactPublicKeyForPrivateInvoicePaymentHash(paymentHash: String): String? = + withContext(ioDispatcher) { + if (paymentHash.isBlank()) return@withContext null + cacheStore.data.first().contacts.firstNotNullOfOrNull { (publicKey, contactState) -> + publicKey.takeIf { + contactState.localInvoice?.paymentHash == paymentHash || + paymentHash in contactState.receivedInvoicePaymentHashes + } + } + } + + suspend fun contactPublicKeyForPrivateOnchainAddresses(addresses: Collection): String? = + withContext(ioDispatcher) { + addresses.firstNotNullOfOrNull { + addressReservationRepo.get().contactPublicKeyForReservedAddress(it) + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 2e07f3d40c..3205b721d5 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -10,6 +10,7 @@ import to.bitkit.utils.AppError sealed class PrivatePaykitError(message: String, cause: Throwable? = null) : AppError(message, cause) { data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") data object PayloadTooLarge : PrivatePaykitError("Private Paykit payload is too large") + 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) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 51e42d6288..1dd2b37634 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -122,6 +122,8 @@ class PrivatePaykitRepo @Inject constructor( runCatching { if (!canPublishPrivateEndpoints()) return@runCatching publishLocalEndpoints(knownSavedContactKeys.toList(), maxAdvanceSteps = 1, reason = reason).getOrThrow() + }.onFailure { + Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) } } @@ -130,10 +132,14 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runCatching { if (isContactSharingCleanupPending()) { - publicPaykitRepo.syncPublishedEndpoints(publish = false).getOrThrow() - removePublishedEndpoints().getOrThrow() - clearUnsavedContactState(savedPublicKeys).getOrThrow() - updateContactSharingCleanupPending(false) + if (settingsStore.data.first().sharesPublicPaykitEndpoints) { + updateContactSharingCleanupPending(false) + } else { + publicPaykitRepo.syncPublishedEndpoints(publish = false).getOrThrow() + removePublishedEndpoints().getOrThrow() + clearUnsavedContactState(savedPublicKeys).getOrThrow() + updateContactSharingCleanupPending(false) + } } retryPendingDeletedContactEndpointRemoval(savedPublicKeys).getOrThrow() }.onFailure { @@ -172,7 +178,7 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun disableSharingAndClearLocalState(savedPublicKeys: Collection): Result = + suspend fun disableSharingAndPruneUnsavedContactState(savedPublicKeys: Collection): Result = withContext(serializedDispatcher) { runCatching { resetInFlightWork() @@ -239,55 +245,6 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun resolveSavedContactPayableEndpoint(publicKey: String): Result = - withContext(serializedDispatcher) { - runCatching { - val normalizedKey = knownSavedContact(publicKey) - ?: return@runCatching publicPaykitRepo.hasPayablePublicEndpoint(publicKey).getOrThrow() - - val hadCachedPrivateEndpoint = hasCachedPrivateEndpoint(normalizedKey) - val generation = currentStateGeneration() - val linkId = establishedLinkId(normalizedKey, maxAdvanceSteps = 3, generation = generation).getOrNull() - if (linkId == null) { - return@runCatching hadCachedPrivateEndpoint || - publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() - } - - if (ensureState().contacts[normalizedKey]?.lastLocalPayloadHash == null) { - publishLocalEndpointsBestEffort( - publicKey = normalizedKey, - linkId = linkId, - fetchedRemoteCount = 0, - context = "resolve", - generation = generation, - ) - } - val fetchedCount = fetchRemoteEndpoints(normalizedKey, linkId, generation).getOrElse { - Logger.warn( - "Failed to resolve private Paykit endpoints for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - if (hadCachedPrivateEndpoint && !PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) { - return@runCatching true - } - return@runCatching publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() - } - val publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?: linkId - publishLocalEndpointsBestEffort( - publicKey = normalizedKey, - linkId = publishLinkId, - fetchedRemoteCount = fetchedCount, - context = "resolve", - generation = generation, - respectInitialPublishDelay = false, - ) - - hasCachedPrivateEndpoint(normalizedKey) || - publicPaykitRepo.hasPayablePublicEndpoint(normalizedKey).getOrThrow() - } - } - suspend fun discardRemoteLightningEndpoints( publicKey: String, paymentHashes: Set, @@ -431,7 +388,7 @@ class PrivatePaykitRepo @Inject constructor( if (backup == null) { stateStore.replaceState(PrivatePaykitState()) - persistState() + persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() return@runCatching } @@ -465,7 +422,7 @@ class PrivatePaykitRepo @Inject constructor( stateStore.replaceState(PrivatePaykitState(contacts = contacts.toMutableMap())) } } - persistState() + persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() } } @@ -770,6 +727,8 @@ class PrivatePaykitRepo @Inject constructor( 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) @@ -983,26 +942,52 @@ class PrivatePaykitRepo @Inject constructor( } contactState.linkSnapshotHex?.let { snapshot -> - val restoredLinkId = runCatching { - validateSnapshot(snapshot, normalizedKey, pubkyService::encryptedLinkSnapshotRecipient) - val linkId = pubkyService.restoreEncryptedLink(secretKeyHex, snapshot) - ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId = linkId) - linkId - }.onFailure { - if (it is PrivatePaykitError.PrivateUnavailable) throw it + val shouldRestoreSnapshot = runCatching { + snapshotRecipientMatches(snapshot, normalizedKey, pubkyService::encryptedLinkSnapshotRecipient) + }.getOrElse { Logger.warn( - "Failed to restore private Paykit link for '${redacted(normalizedKey)}'", + "Failed to inspect private Paykit link snapshot for '${redacted(normalizedKey)}'", it, context = TAG, ) - contactState.linkSnapshotHex = null - contactState.handshakeSnapshotHex = null - contactState.lastLocalPayloadHash = null - contactState.mainRecoveryAttemptId = null - contactState.responderRecoveryAttemptId = null - persistState(markWalletBackup = true) - }.getOrNull() + clearInvalidLinkSnapshotState(contactState) + false + } + + if (!shouldRestoreSnapshot) { + if (contactState.linkSnapshotHex != null) { + Logger.warn( + "Dropped private Paykit link snapshot with mismatched recipient for " + + "'${redacted(normalizedKey)}'", + context = TAG, + ) + 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)}'", + 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, @@ -1183,12 +1168,35 @@ class PrivatePaykitRepo @Inject constructor( var handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId if (handshakeId == null) { contactState.handshakeSnapshotHex?.let { snapshot -> - runCatching { - validateSnapshot( + 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 { @@ -1493,13 +1501,6 @@ class PrivatePaykitRepo @Inject constructor( return reusable } - private suspend fun hasCachedPrivateEndpoint(publicKey: String): Boolean { - val endpoints = ensureState().contacts[publicKey]?.remoteEndpoints.orEmpty().mapNotNull { - PublicPaykitRepo.parseEndpoint(it.methodId, it.endpointData) - } - return privatePayableEndpoints(endpoints, publicKey).isNotEmpty() - } - private suspend fun shouldDiscardRemoteLightningEntry( entry: StoredPaymentEntry, paymentHashes: Set, @@ -1718,6 +1719,29 @@ class PrivatePaykitRepo @Inject constructor( 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.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, @@ -1741,12 +1765,20 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, recipient: suspend (String) -> String, ) { - val snapshotRecipient = recipient(snapshotHex) - if (PubkyPublicKeyFormat.normalized(snapshotRecipient) != PubkyPublicKeyFormat.normalized(publicKey)) { - throw PrivatePaykitError.PrivateUnavailable + 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) { @@ -1769,8 +1801,11 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun ensureState(): PrivatePaykitState = stateStore.ensureState() - private suspend fun persistState(markWalletBackup: Boolean = false) { - stateStore.persistState(markWalletBackup, ::notifyBackupStateChanged) + private suspend fun persistState( + markWalletBackup: Boolean = false, + preserveCleanupMarkers: Boolean = true, + ) { + stateStore.persistState(markWalletBackup, ::notifyBackupStateChanged, preserveCleanupMarkers) } 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 index 586f8e0cad..61553a64b1 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt @@ -6,11 +6,16 @@ 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 @@ -21,10 +26,19 @@ internal class PrivatePaykitStateStore( suspend fun ensureState(): PrivatePaykitState { state?.let { return it } - val secretState = runCatching { + val serializedSecretState = runCatching { keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) - ?.let { json.decodeFromString(it) } - }.getOrNull() ?: PrivatePaykitSecretState() + }.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 } @@ -33,6 +47,7 @@ internal class PrivatePaykitStateStore( suspend fun persistState( markWalletBackup: Boolean, notifyBackupStateChanged: () -> Unit, + preserveCleanupMarkers: Boolean = true, ) { val current = state ?: return runCatching { @@ -45,8 +60,12 @@ internal class PrivatePaykitStateStore( cacheStore.update { stored -> current.cacheState( - cleanupPending = stored.cleanupPending, - deletedContactCleanupPendingPublicKeys = stored.deletedContactCleanupPendingPublicKeys, + cleanupPending = if (preserveCleanupMarkers) stored.cleanupPending else false, + deletedContactCleanupPendingPublicKeys = if (preserveCleanupMarkers) { + stored.deletedContactCleanupPendingPublicKeys + } else { + emptySet() + }, ) } if (markWalletBackup) notifyBackupStateChanged() diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b23440e1e8..58fb3fbb03 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -262,7 +262,7 @@ class WalletRepo @Inject constructor( if (address.isEmpty()) { newAddress() } else if (privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(address)) { - newAddress() + replaceReusableOnchainAddress() } else { checkAddressUsage(address).onSuccess { wasUsed -> if (wasUsed) { @@ -359,6 +359,7 @@ class WalletRepo @Inject constructor( return@runCatching } + clearReusableOnchainAddress() newAddress().getOrThrow() updateBip21Url() }.onFailure { @@ -366,6 +367,16 @@ class WalletRepo @Inject constructor( } } + private suspend fun replaceReusableOnchainAddress(): Result { + clearReusableOnchainAddress() + return newAddress() + } + + private suspend fun clearReusableOnchainAddress() { + _walletState.update { it.copy(onchainAddress = "", bip21 = "") } + cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } + } + suspend fun refreshReceiveAddressAfterTypeChange(): Result = withContext(bgDispatcher) { runCatching { cacheStore.update { it.resetBip21() } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 57cfa99040..a9a8cd23d7 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -84,10 +84,12 @@ import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.models.toCoreNetwork import to.bitkit.models.toSettingsString +import to.bitkit.repositories.PrivatePaykitContactResolver import to.bitkit.utils.AppError import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import kotlin.random.Random import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails @@ -103,6 +105,7 @@ class CoreService @Inject constructor( private val httpClient: HttpClient, private val cacheStore: CacheStore, private val settingsStore: SettingsStore, + private val privatePaykitContactResolver: Provider, ) { private var walletIndex: Int = 0 @@ -112,6 +115,7 @@ class CoreService @Inject constructor( cacheStore = cacheStore, lightningService = lightningService, settingsStore = settingsStore, + privatePaykitContactResolver = privatePaykitContactResolver, ) } val blocktank: BlocktankService by lazy { @@ -219,18 +223,8 @@ class ActivityService( private val cacheStore: CacheStore, private val lightningService: LightningService, private val settingsStore: SettingsStore, + private val privatePaykitContactResolver: Provider, ) { - private var privateInvoiceContactResolver: (suspend (String) -> String?)? = null - private var privateOnchainAddressContactResolver: (suspend (String) -> String?)? = null - - fun setPrivatePaykitContactResolvers( - invoice: (suspend (String) -> String?)?, - onchainAddress: (suspend (String) -> String?)?, - ) { - privateInvoiceContactResolver = invoice - privateOnchainAddressContactResolver = onchainAddress - } - suspend fun removeAll() { ServiceQueue.CORE.background { // Get all activities and delete them one by one @@ -592,22 +586,17 @@ class ActivityService( return null } - findAddressInPreActivityMetadata(details)?.let { - return it - } - val currentWalletAddress = cacheStore.data.first().onchainAddress val selectedAddressType = settingsStore.data.first().selectedAddressType.toAddressType() ?: DEFAULT_ADDRESS_TYPE - searchReceivingAddressWithLdk( - details = details, - value = payment.amountSats ?: 0u, - currentWalletAddress = currentWalletAddress, - selectedAddressType = selectedAddressType, - )?.let { - return it - } - return findPrivateReservedAddress(details) + return findAddressInPreActivityMetadata(details) + ?: searchReceivingAddressWithLdk( + details = details, + value = payment.amountSats ?: 0u, + currentWalletAddress = currentWalletAddress, + selectedAddressType = selectedAddressType, + ) + ?: findPrivateReservedAddress(details) } private suspend fun findPrivateReservedAddress(details: BitkitCoreTransactionDetails): String? { @@ -964,11 +953,11 @@ class ActivityService( direction: PaymentDirection, ): String? { if (direction != PaymentDirection.INBOUND) return null - return privateInvoiceContactResolver?.invoke(paymentHash) + return privatePaykitContactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) } private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = - privateOnchainAddressContactResolver?.invoke(address) + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses(listOf(address)) // MARK: - Test Data Generation (regtest only) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 98ce6be92b..08caea71a5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -110,7 +110,7 @@ private fun Content( currentProfile != null -> ContactBody( profile = currentProfile, tags = uiState.tags, - hasPaymentEndpoint = uiState.hasPaymentEndpoint, + showPayButton = uiState.showPayButton, onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickPay = onClickPay, @@ -136,7 +136,7 @@ private fun Content( private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, - hasPaymentEndpoint: Boolean, + showPayButton: Boolean, onClickEdit: () -> Unit, onClickCopy: () -> Unit, onClickPay: () -> Unit, @@ -170,7 +170,7 @@ private fun ContactBody( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - if (hasPaymentEndpoint) { + if (showPayButton) { ActionButton( onClick = onClickPay, iconRes = R.drawable.ic_coins, @@ -288,6 +288,7 @@ private fun Preview() { status = null, ), tags = persistentListOf("CEO", "Bitcoin"), + showPayButton = true, ), onBackClick = {}, onClickEdit = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 4de7067287..dd15c57f51 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -68,7 +68,7 @@ class ContactDetailViewModel @Inject constructor( it.copy( profile = cached, tags = cached.tags.toImmutableList(), - hasPaymentEndpoint = true, + showPayButton = true, isLoading = false, ) } @@ -80,7 +80,7 @@ class ContactDetailViewModel @Inject constructor( it.copy( profile = profile, tags = profile.tags.toImmutableList(), - hasPaymentEndpoint = true, + showPayButton = true, isLoading = false, ) } @@ -190,7 +190,7 @@ data class ContactDetailUiState( val profile: PubkyProfile? = null, val tags: ImmutableList = persistentListOf(), val isLoading: Boolean = false, - val hasPaymentEndpoint: Boolean = false, + val showPayButton: Boolean = false, val showAddTagSheet: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 91f5080da9..248c034030 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -23,7 +23,6 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -34,10 +33,6 @@ class PayContactsViewModel @Inject constructor( private val privatePaykitRepo: PrivatePaykitRepo, private val pubkyRepo: PubkyRepo, ) : ViewModel() { - companion object { - private const val TAG = "PayContactsViewModel" - } - private val _uiState = MutableStateFlow(PayContactsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -79,7 +74,6 @@ class PayContactsViewModel @Inject constructor( .onFailure { val settings = settingsStore.data.first() val persistedValue = resolvedSharingDefault(settings) - Logger.error("Failed to sync public Paykit endpoints", it, context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), @@ -99,6 +93,12 @@ class PayContactsViewModel @Inject constructor( publicPaykitRepo.syncPublishedEndpoints(publish = true) .onFailure { return Result.failure(it) } + privatePaykitRepo.setContactSharingCleanupPending(false) + .onFailure { + publicPaykitRepo.syncPublishedEndpoints(publish = false) + return Result.failure(it) + } + runCatching { settingsStore.update { it.copy( @@ -110,14 +110,7 @@ class PayContactsViewModel @Inject constructor( return Result.failure(it) } - privatePaykitRepo.setContactSharingCleanupPending(false) - .onFailure { - Logger.warn("Failed to clear private Paykit cleanup marker", it, context = TAG) - } privatePaykitRepo.prepareSavedContacts(contacts) - .onFailure { - Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) - } return Result.success(Unit) } @@ -136,21 +129,18 @@ class PayContactsViewModel @Inject constructor( var cleanupError: Throwable? = null publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onFailure { - cleanupError = it - Logger.warn("Failed to remove public Paykit endpoints", it, context = TAG) - } + .onFailure { cleanupError = it } - privatePaykitRepo.disableSharingAndClearLocalState(contacts) + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) .onFailure { if (cleanupError == null) cleanupError = it - Logger.warn("Failed to remove private Paykit endpoints", it, context = TAG) } cleanupError?.let { privatePaykitRepo.setContactSharingCleanupPending(true) - .onFailure { error -> - Logger.warn("Failed to mark private Paykit cleanup pending", error, context = TAG) + .onFailure { markerError -> + it.addSuppressed(markerError) + return Result.failure(it) } return Result.failure(it) } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt index 9d6924320b..7f4a9d5212 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModel.kt @@ -30,7 +30,6 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -42,10 +41,6 @@ class AddressTypePreferenceViewModel @Inject constructor( private val walletRepo: WalletRepo, private val privatePaykitRepo: PrivatePaykitRepo, ) : ViewModel() { - companion object { - private const val TAG = "AddressTypePreferenceViewModel" - } - private val _uiState = MutableStateFlow(AddressTypePreferenceUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -90,9 +85,6 @@ class AddressTypePreferenceViewModel @Inject constructor( ).onSuccess { walletRepo.refreshReceiveAddressAfterTypeChange() privatePaykitRepo.refreshKnownSavedContactEndpoints("address type changed") - .onFailure { - Logger.warn("Failed to refresh private Paykit after address type change", it, context = TAG) - } } _uiState.update { it.copy(isLoading = false) } @@ -134,13 +126,6 @@ class AddressTypePreferenceViewModel @Inject constructor( val repoResult = lightningRepo.setMonitoring(addressType, enabled) .onSuccess { privatePaykitRepo.refreshKnownSavedContactEndpoints("address monitoring changed") - .onFailure { - Logger.warn( - "Failed to refresh private Paykit after address monitoring changed", - it, - context = TAG, - ) - } } _uiState.update { it.copy(isLoading = false) } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt index acc5c6562c..ec01d89526 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.settings.advanced import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -124,42 +125,12 @@ private fun AddressViewerContent( .fillMaxSize() ) { VerticalSpacer(16.dp) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, + AddressPreview( + selectedAddress = uiState.selectedAddress, + onCopy = onCopy, + onClickOpenBlockExplorer = onClickOpenBlockExplorer, modifier = Modifier.fillMaxWidth() - ) { - QrCodeImage( - content = uiState.selectedAddress?.address.orEmpty(), - size = 120.dp, - modifier = Modifier - .size(120.dp) - .clickableAlpha { onCopy(uiState.selectedAddress?.address.orEmpty()) } - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - BodyS( - text = stringResource(R.string.settings__addr__index) - .replace("{index}", (uiState.selectedAddress?.index ?: 0).toString()), - color = Colors.White80 - ) - BodyS( - text = stringResource(R.string.settings__addr__path) - .replace("{path}", uiState.selectedAddress?.path.orEmpty()), - color = Colors.White80, - modifier = Modifier.testTag("Path") - ) - BodyS( - text = stringResource(R.string.wallet__activity_explorer), - color = Colors.White80, - modifier = Modifier.clickableAlpha { - onClickOpenBlockExplorer(uiState.selectedAddress?.address.orEmpty()) - } - ) - } - } + ) VerticalSpacer(16.dp) SearchInput( @@ -223,7 +194,11 @@ private fun AddressViewerContent( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f) ) { - if (uiState.addresses.isEmpty()) { + if (uiState.loadError && uiState.addresses.isEmpty()) { + item { + ListMessage(stringResource(R.string.settings__addr__load_error)) + } + } else if (uiState.addresses.isEmpty()) { item { ListMessage(stringResource(R.string.settings__addr__loading)) } @@ -234,6 +209,10 @@ private fun AddressViewerContent( .replace("{searchTxt}", uiState.searchText) ) } + } else if (uiState.loadError) { + item { + ListMessage(stringResource(R.string.settings__addr__load_error)) + } } items(filteredAddresses) { address -> AddressItem( @@ -274,6 +253,58 @@ private fun AddressViewerContent( } } +@Composable +private fun AddressPreview( + selectedAddress: AddressModel?, + modifier: Modifier = Modifier, + onCopy: (String) -> Unit, + onClickOpenBlockExplorer: (String) -> Unit, +) { + val selectedAddressText = selectedAddress?.address.orEmpty() + val hasSelectedAddress = selectedAddressText.isNotBlank() + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + modifier = modifier + ) { + Box(modifier = Modifier.size(120.dp)) { + if (hasSelectedAddress) { + QrCodeImage( + content = selectedAddressText, + size = 120.dp, + modifier = Modifier + .size(120.dp) + .clickableAlpha { onCopy(selectedAddressText) } + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + BodyS( + text = stringResource(R.string.settings__addr__index) + .replace("{index}", (selectedAddress?.index ?: 0).toString()), + color = Colors.White80 + ) + BodyS( + text = stringResource(R.string.settings__addr__path) + .replace("{path}", selectedAddress?.path.orEmpty()), + color = Colors.White80, + modifier = Modifier.testTag("Path") + ) + BodyS( + text = stringResource(R.string.wallet__activity_explorer), + color = if (hasSelectedAddress) Colors.White80 else Colors.White32, + modifier = Modifier.clickableAlpha(enabled = hasSelectedAddress) { + onClickOpenBlockExplorer(selectedAddressText) + } + ) + } + } +} + @Composable private fun ListMessage(text: String) { Caption( diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt index a64d1ef987..cff4d36ac2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerViewModel.kt @@ -52,23 +52,32 @@ class AddressViewerViewModel @Inject constructor( fun loadAddresses() { viewModelScope.launch(bgDispatcher) { - runCatching { - _uiState.update { it.copy(isLoading = true) } - - delay(300) // wait for screen transition + _uiState.update { + it.copy( + addresses = persistentListOf(), + selectedAddress = null, + balances = persistentMapOf(), + isLoading = true, + loadError = false, + ) + } - val addresses = walletRepo.getAddresses( - isChange = !_uiState.value.showReceiveAddresses, - addressType = _uiState.value.selectedAddressType, - ).getOrThrow() + delay(300) // wait for screen transition + walletRepo.getAddresses( + isChange = !_uiState.value.showReceiveAddresses, + addressType = _uiState.value.selectedAddressType, + ).onSuccess { addresses -> _uiState.update { currentState -> currentState.copy( addresses = addresses.toImmutableList(), selectedAddress = addresses.firstOrNull(), + loadError = false, ) } loadBalancesForAddresses(addresses) + }.onFailure { + _uiState.update { it.copy(loadError = true) } } _uiState.update { it.copy(isLoading = false) } @@ -79,22 +88,25 @@ class AddressViewerViewModel @Inject constructor( if (_uiState.value.isLoading) return viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(isLoading = true) } + _uiState.update { it.copy(isLoading = true, loadError = false) } - runCatching { - val currentState = _uiState.value - val nextStartIndex = currentState.addresses.size - - val newAddresses = walletRepo.getAddresses( - startIndex = nextStartIndex, - isChange = !currentState.showReceiveAddresses, - addressType = currentState.selectedAddressType, - ).getOrThrow() + val currentState = _uiState.value + val nextStartIndex = currentState.addresses.size + walletRepo.getAddresses( + startIndex = nextStartIndex, + isChange = !currentState.showReceiveAddresses, + addressType = currentState.selectedAddressType, + ).onSuccess { newAddresses -> _uiState.update { currentState -> - currentState.copy(addresses = (currentState.addresses + newAddresses).toImmutableList()) + currentState.copy( + addresses = (currentState.addresses + newAddresses).toImmutableList(), + loadError = false, + ) } loadBalancesForAddresses(newAddresses) + }.onFailure { + _uiState.update { it.copy(loadError = true) } } _uiState.update { it.copy(isLoading = false) } @@ -122,23 +134,33 @@ class AddressViewerViewModel @Inject constructor( if (_uiState.value.showReceiveAddresses == isReceiving) return viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(showReceiveAddresses = isReceiving, isLoading = true) } - - runCatching { - val addresses = walletRepo.getAddresses( - isChange = !isReceiving, - addressType = _uiState.value.selectedAddressType, - ).getOrThrow() + _uiState.update { + it.copy( + showReceiveAddresses = isReceiving, + addresses = persistentListOf(), + selectedAddress = null, + balances = persistentMapOf(), + isLoading = true, + loadError = false, + ) + } + walletRepo.getAddresses( + isChange = !isReceiving, + addressType = _uiState.value.selectedAddressType, + ).onSuccess { addresses -> _uiState.update { currentState -> currentState.copy( addresses = addresses.toImmutableList(), selectedAddress = addresses.firstOrNull(), - balances = persistentMapOf(), // Clear balances for new address type + balances = persistentMapOf(), + loadError = false, ) } loadBalancesForAddresses(addresses) + }.onFailure { + _uiState.update { it.copy(loadError = true) } } _uiState.update { it.copy(isLoading = false) } @@ -153,22 +175,32 @@ class AddressViewerViewModel @Inject constructor( if (_uiState.value.selectedAddressType == addressType) return viewModelScope.launch(bgDispatcher) { - _uiState.update { it.copy(selectedAddressType = addressType, isLoading = true) } - - runCatching { - val addresses = walletRepo.getAddresses( - isChange = !_uiState.value.showReceiveAddresses, - addressType = addressType, - ).getOrThrow() + _uiState.update { + it.copy( + selectedAddressType = addressType, + addresses = persistentListOf(), + selectedAddress = null, + balances = persistentMapOf(), + isLoading = true, + loadError = false, + ) + } + walletRepo.getAddresses( + isChange = !_uiState.value.showReceiveAddresses, + addressType = addressType, + ).onSuccess { addresses -> _uiState.update { currentState -> currentState.copy( addresses = addresses.toImmutableList(), selectedAddress = addresses.firstOrNull(), balances = persistentMapOf(), + loadError = false, ) } loadBalancesForAddresses(addresses) + }.onFailure { + _uiState.update { it.copy(loadError = true) } } _uiState.update { it.copy(isLoading = false) } @@ -223,6 +255,7 @@ data class UiState( val selectedAddress: AddressModel? = null, val isLoading: Boolean = false, val isLoadingBalances: Boolean = false, + val loadError: Boolean = false, val showReceiveAddresses: Boolean = true, val selectedAddressType: AddressType = AddressType.P2WPKH, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 66b329b4ac..1888352f14 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -293,14 +293,6 @@ class AppViewModel @Inject constructor( } init { - coreService.activity.setPrivatePaykitContactResolvers( - invoice = { paymentHash -> - privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) - }, - onchainAddress = { address -> - privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(listOf(address)) - }, - ) viewModelScope.launch { ToastEventBus.events.collect { toast(it) @@ -489,9 +481,6 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason) - .onFailure { - Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) - } } @Suppress("CyclomaticComplexMethod") @@ -808,10 +797,20 @@ class AppViewModel @Inject constructor( } return } + if (closeActiveSendForFailedPayment(paymentHash, event.reason)) return } notifyPaymentFailed(event.reason) } + private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { + val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() + if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false + + notifyPaymentFailed(reason) + hideSheet() + return true + } + private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 790513476c..74cfb418b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -678,6 +678,7 @@ Check Balances Generate 20 More Index: {index} + Wallet is still starting. Try again in a moment. Loading Addresses... No Addresses To Display No addresses found when searching for \"{searchTxt}\" diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index 864ecd206e..60a492cd0a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -19,6 +19,7 @@ import to.bitkit.services.AddressDerivationInfo import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { @@ -154,6 +155,18 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { verify(lightningRepo).revealReceiveAddresses(505, AddressType.P2WPKH) } + @Test + fun `isUnavailableForReusableReceive does not scan restored private receive ceilings by address`() = test { + reservationData.value = PrivatePaykitReservationData( + restoredReservedReceiveIndexCeilingsByAddressType = mapOf("nativeSegwit" to 505), + ) + + val result = sut.isUnavailableForReusableReceive(PRIVATE_ADDRESS) + + assertFalse(result) + verify(lightningRepo, never()).addressInfosForType(any(), any(), any(), any()) + } + @Test fun `clearContactAssignment removes private address attribution history`() = test { reservationData.value = PrivatePaykitReservationData( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt new file mode 100644 index 0000000000..6fc30b101e --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt @@ -0,0 +1,90 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.data.PrivatePaykitCacheData +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.PrivatePaykitContactCacheData +import to.bitkit.data.PrivatePaykitStoredInvoiceData +import to.bitkit.test.BaseUnitTest +import javax.inject.Provider +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PrivatePaykitContactResolverTest : BaseUnitTest() { + companion object { + private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val PAYMENT_HASH = "010203" + private const val PRIVATE_ADDRESS = "bcrt1qterdweva9vextackckt6pjy0mmuc54g87g6lsq" + } + + private val cacheStore = mock() + private val addressReservationRepo = mock() + private val cacheData = MutableStateFlow(PrivatePaykitCacheData()) + + private lateinit var sut: PrivatePaykitContactResolver + + @Before + fun setUp() { + whenever(cacheStore.data).thenReturn(cacheData) + sut = PrivatePaykitContactResolver( + ioDispatcher = testDispatcher, + cacheStore = cacheStore, + addressReservationRepo = Provider { addressReservationRepo }, + ) + } + + @Test + fun `contactPublicKeyForPrivateInvoicePaymentHash resolves current local invoice`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + localInvoice = PrivatePaykitStoredInvoiceData( + bolt11 = "lnbcrt1private", + paymentHash = PAYMENT_HASH, + expiresAt = 1_700_000_000L, + ), + ), + ), + ) + + val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) + + assertEquals(CONTACT_KEY, result) + } + + @Test + fun `contactPublicKeyForPrivateInvoicePaymentHash resolves remembered received invoice`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + receivedInvoicePaymentHashes = listOf(PAYMENT_HASH), + ), + ), + ) + + val result = sut.contactPublicKeyForPrivateInvoicePaymentHash(PAYMENT_HASH) + + assertEquals(CONTACT_KEY, result) + } + + @Test + fun `contactPublicKeyForPrivateInvoicePaymentHash ignores blank hash`() = test { + val result = sut.contactPublicKeyForPrivateInvoicePaymentHash("") + + assertNull(result) + } + + @Test + fun `contactPublicKeyForPrivateOnchainAddresses resolves reserved address`() = test { + whenever(addressReservationRepo.contactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) + .thenReturn(CONTACT_KEY) + + val result = sut.contactPublicKeyForPrivateOnchainAddresses(listOf(PRIVATE_ADDRESS)) + + assertEquals(CONTACT_KEY, result) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index f0918ccb65..b71330941e 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -51,6 +51,7 @@ 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 { @@ -58,9 +59,11 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 PRIVATE_BOLT11 = "lnbcrt1private" private const val PRIVATE_PAYMENT_HASH = "010203" @@ -106,7 +109,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) 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 { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) + whenever { addressReservationRepo.hasContactAssignment(any()) }.thenReturn(false) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) sut = createSut() @@ -169,6 +175,19 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals("responder-attempt", restored.responderRecoveryAttemptId) } + @Test + fun `restoreBackup clears stale private cleanup markers`() = test { + cacheData.value = PrivatePaykitCacheData( + cleanupPending = true, + deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY), + ) + + sut.restoreBackup(null).getOrThrow() + + assertFalse(cacheData.value.cleanupPending) + assertEquals(emptySet(), cacheData.value.deletedContactCleanupPendingPublicKeys) + } + @Test fun `removeSavedContact tombstones private endpoints before clearing local state`() = test { restoreContactBackup() @@ -233,6 +252,17 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertNull(sut.backupSnapshot().getOrThrow()) } + @Test + fun `retryPendingEndpointRemoval clears stale sharing cleanup marker when sharing is enabled`() = test { + cacheData.value = cacheData.value.copy(cleanupPending = true) + settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + + 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() @@ -320,6 +350,47 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(pubkyService, never()).setPrivatePayments(any(), any()) } + @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() @@ -353,6 +424,35 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @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(emptyList()) + 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 map with compact payload json`() = test { val bolt11 = "l".repeat(PAYLOAD_LIMIT_BOLT11_LENGTH) @@ -627,6 +727,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 startForegroundWithSharingEnabled() { settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) whenever(walletRepo.walletExists()).thenReturn(true) @@ -664,8 +771,16 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { payeeNodeId = null, ) - private fun secretStateJson(): String = - """{"contacts":{"$CONTACT_KEY":{"linkSnapshotHex":"$LINK_SNAPSHOT","handshakeSnapshotHex":null}}}""" + 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( diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4bb573c570..a652ba37f8 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -80,6 +80,7 @@ class WalletRepoTest : BaseUnitTest() { fun setUp() = runBlocking { whenever(coreService.isGeoBlocked()).thenReturn(false) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = "", onchainAddress = ADDRESS))) + whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) @@ -329,6 +330,41 @@ class WalletRepoTest : BaseUnitTest() { verify(cacheStore).setOnchainAddress(ADDRESS) } + @Test + fun `refreshReusableReceiveAddressIfReserved replaces unavailable cached address`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) + whenever { privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(ADDRESS) } + .thenReturn(true) + sut = createSut() + sut.loadFromCache() + + val result = sut.refreshReusableReceiveAddressIfReserved() + + assertTrue(result.isSuccess) + assertEquals(ADDRESS_NEW, sut.walletState.value.onchainAddress) + assertEquals(INVOICE, sut.walletState.value.bolt11) + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() + verify(cacheStore).setOnchainAddress(ADDRESS_NEW) + } + + @Test + fun `refreshReusableReceiveAddressIfReserved clears unavailable cached address when replacement fails`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) + whenever { privatePaykitAddressReservationRepo.isUnavailableForReusableReceive(ADDRESS) } + .thenReturn(true) + whenever { privatePaykitAddressReservationRepo.nextReusableReceiveAddress() } + .thenReturn(Result.failure(error)) + sut = createSut() + sut.loadFromCache() + + val result = sut.refreshReusableReceiveAddressIfReserved() + + assertTrue(result.isFailure) + assertEquals("", sut.walletState.value.onchainAddress) + assertEquals(INVOICE, sut.walletState.value.bolt11) + verify(cacheStore).update(any()) + } + @Test fun `setBolt11 should update storage and state`() = test { sut.setBolt11(INVOICE) 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 a717362a7e..eba7c5dd1b 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 @@ -57,7 +57,7 @@ class PayContactsViewModelTest : BaseUnitTest() { whenever { privatePaykitRepo.setContactSharingCleanupPending(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.prepareSavedContacts(any>()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) } @@ -79,7 +79,30 @@ class PayContactsViewModelTest : BaseUnitTest() { verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) verify(privatePaykitRepo).setContactSharingCleanupPending(false) verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY)) - verify(privatePaykitRepo, never()).disableSharingAndClearLocalState(any>()) + verify(privatePaykitRepo, never()).disableSharingAndPruneUnsavedContactState(any>()) + } + + @Test + fun `continueToProfile keeps sharing disabled when cleanup marker clear fails`() = test { + whenever { privatePaykitRepo.setContactSharingCleanupPending(false) } + .thenReturn(Result.failure(PayContactsTestAppError("marker failed"))) + val sut = createSut() + advanceUntilIdle() + + sut.effects.test { + sut.setPaymentSharingEnabled(true) + sut.continueToProfile() + advanceUntilIdle() + + expectNoEvents() + } + + assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(sut.uiState.value.isLoading) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = true) + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(privatePaykitRepo, never()).prepareSavedContacts(any>()) } @Test @@ -123,7 +146,7 @@ class PayContactsViewModelTest : BaseUnitTest() { assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) - verify(privatePaykitRepo).disableSharingAndClearLocalState(listOf(CONTACT_KEY)) + verify(privatePaykitRepo).disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY)) verify(privatePaykitRepo).setContactSharingCleanupPending(false) assertFalse(sut.uiState.value.isLoading) } @@ -134,7 +157,7 @@ class PayContactsViewModelTest : BaseUnitTest() { hasConfirmedPublicPaykitEndpoints = true, sharesPublicPaykitEndpoints = true, ) - whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.failure(PayContactsTestAppError("cleanup failed"))) val sut = createSut() advanceUntilIdle() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b866aa641d..2e5394d12e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test @@ -40,6 +41,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo @@ -60,6 +62,7 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.usecases.FormatMoneyValue +import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -67,6 +70,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class AppViewModelSendFlowTest : BaseUnitTest() { private lateinit var sut: AppViewModel @@ -149,7 +153,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.retryPendingEndpointRemoval(any>()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.disableSharingAndClearLocalState(any>()) } + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removeSavedContact(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReceivedPayments() }.thenReturn(Result.success(Unit)) @@ -394,6 +398,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `active lightning send failure hides send sheet`() = test { + val bolt11 = "lnbcrt1activefailure" + val paymentHash = "010203" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = null, + ), + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + @Test fun `preserveContactPaymentContext moves active context to pending`() = test { val paymentHash = "pending_hash" @@ -470,6 +502,173 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `private onchain contact payment discards remote address after send`() = test { + val address = "bcrt1qprivatecontact" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = 1000u, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = address, + amount = 1000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo).discardRemoteOnchainEndpoints(contactKey, setOf(address)) + } + + @Test + fun `non-contact onchain payment does not discard private endpoint`() = test { + val address = "bcrt1qpublicpayment" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = 1000u, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + amount = 1000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo, never()).discardRemoteOnchainEndpoints(any(), any()) + } + + @Test + fun `private lightning contact payment discards remote invoice after send`() = test { + val bolt11 = "lnbcrt1privatecontact" + val paymentHash = "payment_hash" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + } + + @Test + fun `private lightning pending payment discards remote invoice`() = test { + val bolt11 = "lnbcrt1pending" + val paymentHash = "pending_hash" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) + .thenReturn(Result.failure(PaymentPendingException(paymentHash))) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + } + + @Test + fun `private lightning duplicate payment discards decoded invoice`() = test { + val bolt11 = "lnbcrt1duplicate" + val contactKey = "pubkycontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) + .thenReturn(Result.failure(AppError("DuplicatePayment"))) + setActiveContactPaymentContext(contactKey) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + } + + @Test + fun `non-contact lightning payment does not discard private invoice`() = test { + val bolt11 = "lnbcrt1public" + val paymentHash = "payment_hash" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) + setSendState( + SendUiState( + address = bolt11, + amount = 1000u, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) + } + @Test fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { enablePublicPaykitSharing() @@ -603,6 +802,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).pruneUnsavedContactState(emptySet()) } + private suspend fun TestScope.confirmCurrentPayment() { + sut.setSendEvent(SendEvent.SwipeToPay) + advanceUntilIdle() + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + } + private fun enableQuickPay(thresholdSats: ULong) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) diff --git a/changelog.d/next/private-paykit.added.md b/changelog.d/next/936.added.md similarity index 100% rename from changelog.d/next/private-paykit.added.md rename to changelog.d/next/936.added.md From 4d04ed7736bdfa06fd1f56fa911322b3c466094a Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 15 May 2026 07:40:00 -0500 Subject: [PATCH 4/5] fix: route pubky scans from send --- app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index d54b7631cd..4851adc8c8 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -165,7 +165,7 @@ fun SendSheet( onBack = { navController.popBackStack() }, onScanSuccess = { navController.popBackStack() - appViewModel.onScanResult(data = it) + appViewModel.onScanResult(data = it, routePubkyKeys = true) }, ) } From da0bb95c1415139f5585d7254241d413daaeedeb Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 15 May 2026 09:25:36 -0500 Subject: [PATCH 5/5] fix: refresh private paykit lightning endpoints --- .../bitkit/repositories/PrivatePaykitRepo.kt | 115 ++++++++++++------ .../bitkit/repositories/PublicPaykitRepo.kt | 23 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 49 ++++++-- .../AddressTypePreferenceViewModelTest.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 9 +- 5 files changed, 144 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 1dd2b37634..aa9ab96596 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -118,10 +118,18 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun refreshKnownSavedContactEndpoints(reason: String): Result = withContext(serializedDispatcher) { + suspend fun refreshKnownSavedContactEndpoints( + reason: String, + forceRefreshLightning: Boolean = false, + ): Result = withContext(serializedDispatcher) { runCatching { if (!canPublishPrivateEndpoints()) return@runCatching - publishLocalEndpoints(knownSavedContactKeys.toList(), maxAdvanceSteps = 1, reason = reason).getOrThrow() + publishLocalEndpoints( + publicKeys = knownSavedContactKeys.toList(), + maxAdvanceSteps = 1, + reason = reason, + forceRefreshLightning = forceRefreshLightning, + ).getOrThrow() }.onFailure { Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG) } @@ -444,41 +452,61 @@ class PrivatePaykitRepo @Inject constructor( ) } + var staleFetchError: Throwable? = null val fetchedCount = fetchRemoteEndpoints(publicKey, linkId, generation).getOrElse { - if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) throw it - Logger.warn( - "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", - it, - context = TAG, - ) + 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 { + Logger.warn( + "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } 0 } - val publishLinkId = activeHandlesByContact[publicKey]?.linkId ?: linkId - publishLocalEndpointsBestEffort( - publicKey = publicKey, - linkId = publishLinkId, - fetchedRemoteCount = fetchedCount, - context = "payment", - generation = generation, - respectInitialPublishDelay = false, - ) - - 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@runCatching when { - cachedEntries.isEmpty() -> PublicPaykitPaymentResult.NoEndpoint - else -> PublicPaykitPaymentResult.NotOpened - } + if (staleFetchError == null) { + val publishLinkId = activeHandlesByContact[publicKey]?.linkId ?: linkId + publishLocalEndpointsBestEffort( + publicKey = publicKey, + linkId = publishLinkId, + fetchedRemoteCount = fetchedCount, + context = "payment", + generation = generation, + respectInitialPublishDelay = false, + ) } - PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable)) + val cachedResult = cachedPrivatePaymentResult(publicKey) + if (cachedResult is PublicPaykitPaymentResult.Opened) return@runCatching cachedResult + staleFetchError?.let { throw it } + + cachedResult + } + } + + 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 } } + return PublicPaykitPaymentResult.Opened(PublicPaykitRepo.paymentRequest(payable)) + } + @Suppress("CyclomaticComplexMethod") private suspend fun publishLocalEndpoints( publicKeys: Collection, @@ -486,6 +514,7 @@ class PrivatePaykitRepo @Inject constructor( reason: String, scheduleRetries: Boolean = true, forceLocalPublishWhenRemoteEmpty: Boolean = false, + forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { runCatching { val generation = currentStateGeneration() @@ -521,6 +550,7 @@ class PrivatePaykitRepo @Inject constructor( linkId = publishLinkId, force = shouldForcePublish, generation = generation, + forceRefreshLightning = forceRefreshLightning, ).onFailure { if (scheduleRetries) schedulePendingPublicationRetry(normalizedKey) Logger.warn( @@ -631,12 +661,18 @@ class PrivatePaykitRepo @Inject constructor( 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, linkId, generation = generation).onFailure { + publishLocalEndpoints( + publicKey = publicKey, + linkId = linkId, + generation = generation, + forceRefreshLightning = forceRefreshLightning, + ).onFailure { schedulePendingPublicationRetry(publicKey) Logger.warn( "Failed to publish private Paykit endpoints during '$context' for '${redacted(publicKey)}'", @@ -707,13 +743,14 @@ class PrivatePaykitRepo @Inject constructor( linkId: String, force: Boolean = false, generation: Long = currentStateGeneration(), + forceRefreshLightning: Boolean = false, ): Result = withContext(serializedDispatcher) { runCatching { publicationMutex.withLock { ensureCurrentGeneration(generation) if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock - val endpoints = buildLocalEndpoints(publicKey).getOrThrow() + val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() ensureCurrentGeneration(generation) val payloadSelection = PrivatePaykitPayloads.entriesWithinNoiseLimit(endpoints) if (payloadSelection.droppedLightning) { @@ -741,7 +778,10 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun buildLocalEndpoints(publicKey: String): Result> = + private suspend fun buildLocalEndpoints( + publicKey: String, + forceRefreshLightning: Boolean = false, + ): Result> = withContext(serializedDispatcher) { runCatching { val endpoints = mutableListOf() @@ -754,7 +794,7 @@ class PrivatePaykitRepo @Inject constructor( ) if (lightningRepo.canReceive()) { - currentOrRotatedInvoice(publicKey).onSuccess { invoice -> + currentOrRotatedInvoice(publicKey, forceRefresh = forceRefreshLightning).onSuccess { invoice -> endpoints += Endpoint( methodId = MethodId.Bolt11, value = invoice.bolt11, @@ -778,17 +818,20 @@ class PrivatePaykitRepo @Inject constructor( } } - private suspend fun currentOrRotatedInvoice(publicKey: String): Result = + private suspend fun currentOrRotatedInvoice( + publicKey: String, + forceRefresh: Boolean = false, + ): Result = withContext(serializedDispatcher) { runCatching { - reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), ).getOrThrow() - reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice ?: throw PublicPaykitError.InvalidPayload diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 490fa7d4b9..75ce1fb428 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -169,9 +169,14 @@ class PublicPaykitRepo @Inject constructor( } } - suspend fun syncCurrentPublishedEndpoints(): Result = withContext(ioDispatcher) { + suspend fun syncCurrentPublishedEndpoints( + forceRefreshLightning: Boolean = false, + ): Result = withContext(ioDispatcher) { runCatching { - val desired = buildWalletEndpoints(refresh = false) + val desired = buildWalletEndpoints( + refresh = false, + forceRefreshLightning = forceRefreshLightning, + ) applyPublishedEndpoints(desired) } } @@ -239,7 +244,10 @@ class PublicPaykitRepo @Inject constructor( return currentPublicKey } - private suspend fun buildWalletEndpoints(refresh: Boolean): List { + private suspend fun buildWalletEndpoints( + refresh: Boolean, + forceRefreshLightning: Boolean = false, + ): List { if (refresh) { lightningRepo.executeWhenNodeRunning( operationName = "sync public Paykit endpoints", @@ -251,7 +259,7 @@ class PublicPaykitRepo @Inject constructor( val state = walletRepo.walletState.value val endpoints = mutableListOf() - buildPublicBolt11Endpoint()?.let { endpoints += it } + buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it } val onchainAddress = state.onchainAddress if (onchainAddress.isNotBlank()) { @@ -268,7 +276,7 @@ class PublicPaykitRepo @Inject constructor( return endpoints } - private suspend fun buildPublicBolt11Endpoint(): Endpoint? { + private suspend fun buildPublicBolt11Endpoint(forceRefreshLightning: Boolean = false): Endpoint? { if (!lightningRepo.canReceive()) { clearPublicBolt11Metadata() return null @@ -276,7 +284,10 @@ class PublicPaykitRepo @Inject constructor( val settings = settingsStore.data.first() val cachedBolt11 = settings.publicPaykitBolt11 - if (cachedBolt11.isNotBlank() && !settings.shouldRefreshPublicBolt11(clock.now().toEpochMilliseconds())) { + val shouldReuseCachedBolt11 = !forceRefreshLightning && + cachedBolt11.isNotBlank() && + !settings.shouldRefreshPublicBolt11(clock.now().toEpochMilliseconds()) + if (shouldReuseCachedBolt11) { return Endpoint( methodId = MethodId.Bolt11, value = cachedBolt11, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1888352f14..9c0237c170 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -343,6 +343,7 @@ class AppViewModel @Inject constructor( } } observeLdkNodeEvents() + observeLightningUsableChannels() observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() @@ -382,6 +383,24 @@ class AppViewModel @Inject constructor( } } + private fun observeLightningUsableChannels() { + viewModelScope.launch { + var hadUsableChannels = false + lightningRepo.lightningState + .map { state -> state.channels.any { it.isUsable } } + .distinctUntilChanged() + .collect { hasUsableChannels -> + if (hasUsableChannels && !hadUsableChannels) { + refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason = "channel usable", + forceRefreshLightning = true, + ) + } + hadUsableChannels = hasUsableChannels + } + } + } + @OptIn(FlowPreview::class) private fun observePublicPaykitEndpoints() { viewModelScope.launch { @@ -417,14 +436,14 @@ class AppViewModel @Inject constructor( viewModelScope.launch { refreshPrivatePaykitEndpointsIfEnabled("foreground") } } - private suspend fun refreshPublicPaykitEndpointsIfEnabled() { + private suspend fun refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning: Boolean = false) { val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints if (!shouldPublish) return val onchainAddress = walletRepo.walletState.value.onchainAddress if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return - publicPaykitRepo.syncCurrentPublishedEndpoints() + publicPaykitRepo.syncCurrentPublishedEndpoints(forceRefreshLightning = forceRefreshLightning) .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } } @@ -470,7 +489,10 @@ class AppViewModel @Inject constructor( } } - private suspend fun refreshPrivatePaykitEndpointsIfEnabled(reason: String) { + private suspend fun refreshPrivatePaykitEndpointsIfEnabled( + reason: String, + forceRefreshLightning: Boolean = false, + ) { privatePaykitRepo.reconcileReservedReceiveIndexes() .onFailure { Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) @@ -480,7 +502,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to retry private Paykit endpoint removal for '$reason'", it, context = TAG) } - privatePaykitRepo.refreshKnownSavedContactEndpoints(reason) + privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) } @Suppress("CyclomaticComplexMethod") @@ -525,11 +547,23 @@ class AppViewModel @Inject constructor( } private suspend fun handleChannelReady(event: Event.ChannelReady) { + refreshPaykitEndpointsAfterChannelAvailabilityChanged("channel ready") + notifyChannelReady(event) + delay(PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS) + refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason = "channel ready delayed", + forceRefreshLightning = true, + ) + } + + private suspend fun refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason: String, + forceRefreshLightning: Boolean = false, + ) { transferRepo.syncTransferStates() walletRepo.syncBalances() - refreshPublicPaykitEndpointsIfEnabled() - refreshPrivatePaykitEndpointsIfEnabled("channel ready") - notifyChannelReady(event) + refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning = forceRefreshLightning) + refreshPrivatePaykitEndpointsIfEnabled(reason, forceRefreshLightning = forceRefreshLightning) } private suspend fun handleChannelPending() = transferRepo.syncTransferStates() @@ -2957,6 +2991,7 @@ class AppViewModel @Inject constructor( private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L + private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" diff --git a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt index 76b3f2f1d6..47e7573ae3 100644 --- a/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/advanced/AddressTypePreferenceViewModelTest.kt @@ -72,7 +72,7 @@ class AddressTypePreferenceViewModelTest : BaseUnitTest() { ) ) ) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2e5394d12e..d2a285f31a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -147,7 +147,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any()) } + whenever { privatePaykitRepo.refreshKnownSavedContactEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.reconcileReservedReceiveIndexes() } .thenReturn(Result.success(Unit)) @@ -685,7 +685,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = true) } @Test @@ -704,7 +705,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) } @Test @@ -834,7 +835,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private suspend fun enablePublicPaykitSharing() { settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) walletState.value = WalletState(onchainAddress = "bc1qtest") - whenever { publicPaykitRepo.syncCurrentPublishedEndpoints() }.thenReturn(Result.success(Unit)) + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any()) }.thenReturn(Result.success(Unit)) } @Suppress("UNCHECKED_CAST")