diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 890008fb7..477d4f3c9 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 000000000..c5717f394 --- /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 1c6776e68..5c2e0ceed 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 3b6a0b4ec..27bea5d2f 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 b99a47847..081723aec 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) + } + .getOrThrow() + val privateLinks = privatePaykitRepo.get().backupSnapshot() + .onFailure { + Logger.warn("Failed to snapshot private Paykit contact links", it, context = TAG) + } + .getOrThrow() + + 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,30 @@ 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) + if (!parsed.privatePaykitHighestReservedReceiveIndexByAddressType.isNullOrEmpty()) { + cacheStore.update { it.copy(onchainAddress = "", bip21 = "") } + } + 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 2eb6a1ebd..cc35bc5f3 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 000000000..9a013e8e7 --- /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/PrivatePaykitContactResolver.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitContactResolver.kt new file mode 100644 index 000000000..d72cab8dc --- /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/PrivatePaykitErrorClassifier.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitErrorClassifier.kt new file mode 100644 index 000000000..c3bd69d60 --- /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 000000000..3205b721d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -0,0 +1,161 @@ +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 SnapshotRecipientMismatch : PrivatePaykitError("Private Paykit snapshot recipient mismatch") + data object StaleLinkState : PrivatePaykitError("Private Paykit link state changed") + class StatePersistenceFailed(cause: Throwable) : PrivatePaykitError("Failed to persist private Paykit state", cause) +} + +internal data class ContactPaykitHandles( + val linkId: String? = null, + val handshakeId: String? = null, +) + +internal data class PrivatePaykitState( + val contacts: MutableMap = mutableMapOf(), +) { + constructor(secretState: PrivatePaykitSecretState, cacheState: PrivatePaykitCacheData) : this( + 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 000000000..43ce5845b --- /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 000000000..0e7f5fe9b --- /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 new file mode 100644 index 000000000..aa9ab9659 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -0,0 +1,1857 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.FfiPaymentEntry +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 org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.App +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.toHex +import to.bitkit.models.PrivatePaykitContactLinkBackupV1 +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.CoreService +import to.bitkit.services.PubkyService +import to.bitkit.utils.Logger +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 + +@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_RECEIVED_INVOICE_HASHES_PER_CONTACT = 100 + private const val STALE_LINK_FAILURE_THRESHOLD = 3 + private const val HANDSHAKE_COMPLETE = "complete" + private const val RECOVERY_MARKER_STAGE_INIT = "init" + private const val RECOVERY_MARKER_STAGE_RESPONSE = "response" + private const val RECOVERY_MARKER_STAGE_FINAL = "final" + private const val COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS = 5 * 60L + private const val FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS = 8L + private const val PENDING_PUBLICATION_RETRY_ATTEMPTS = 60 + 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 = + PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) + } + + private val stateStore = PrivatePaykitStateStore(keychain, cacheStore) + private val recoveryStore = PrivatePaykitRecoveryStore(pubkyService, keychain) { ensureState() } + private val activeHandlesByContact = mutableMapOf() + private val knownSavedContactKeys = mutableSetOf() + private val linkEstablishmentMutex = Mutex() + private val publicationMutex = Mutex() + private val serializedDispatcher = ioDispatcher.limitedParallelism(1) + private val retryScope = CoroutineScope(SupervisorJob() + serializedDispatcher) + private val pendingPublicationRetryJobs = mutableMapOf() + private val stateGeneration = AtomicLong(0L) + + private val _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, + forceRefreshLightning: Boolean = false, + ): Result = withContext(serializedDispatcher) { + runCatching { + if (!canPublishPrivateEndpoints()) return@runCatching + 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) + } + } + + suspend fun retryPendingEndpointRemoval( + savedPublicKeys: Collection, + ): Result = withContext(serializedDispatcher) { + runCatching { + if (isContactSharingCleanupPending()) { + if (settingsStore.data.first().sharesPublicPaykitEndpoints) { + updateContactSharingCleanupPending(false) + } else { + 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 disableSharingAndPruneUnsavedContactState(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() + stateStore.replaceState(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 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) { + stateStore.replaceState(PrivatePaykitState()) + persistState(preserveCleanupMarkers = false) + notifyBackupStateChanged() + return@runCatching + } + + val contacts = backup.mapNotNull { (publicKey, contactBackup) -> + val normalizedKey = normalizedPublicKey(publicKey) ?: return@mapNotNull null + val linkSnapshotHex = validatedSnapshot( + contactBackup.linkSnapshotHex, + normalizedKey, + pubkyService::encryptedLinkSnapshotRecipient, + ) + val handshakeSnapshotHex = validatedSnapshot( + contactBackup.handshakeSnapshotHex, + normalizedKey, + pubkyService::encryptedLinkHandshakeSnapshotRecipient, + ) + normalizedKey to ContactState( + linkSnapshotHex = linkSnapshotHex, + handshakeSnapshotHex = handshakeSnapshotHex, + remoteEndpoints = PrivatePaykitPayloads.storedPaymentEntries( + contactBackup.remoteEndpoints, + ), + linkCompletedAt = contactBackup.linkCompletedAt, + handshakeUpdatedAt = contactBackup.handshakeUpdatedAt, + recoveryStartedAt = contactBackup.recoveryStartedAt, + mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId, + responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId, + ) + }.toMap() + + stateStore.replaceState(PrivatePaykitState(contacts = contacts.toMutableMap())) + } + } + persistState(preserveCleanupMarkers = false) + 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, + ) + } + + var staleFetchError: Throwable? = null + val fetchedCount = fetchRemoteEndpoints(publicKey, linkId, generation).getOrElse { + if (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) { + Logger.warn( + "Private Paykit link is stale for '${redacted(publicKey)}'; using cached private endpoints", + it, + context = TAG, + ) + staleFetchError = it + schedulePendingPublicationRetry(publicKey) + } else { + Logger.warn( + "Failed to refresh private Paykit endpoints for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + 0 + } + if (staleFetchError == null) { + val publishLinkId = activeHandlesByContact[publicKey]?.linkId ?: linkId + publishLocalEndpointsBestEffort( + publicKey = publicKey, + linkId = publishLinkId, + fetchedRemoteCount = fetchedCount, + context = "payment", + generation = generation, + respectInitialPublishDelay = false, + ) + } + + val cachedResult = cachedPrivatePaymentResult(publicKey) + if (cachedResult is PublicPaykitPaymentResult.Opened) 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, + maxAdvanceSteps: Int, + reason: String, + scheduleRetries: Boolean = true, + forceLocalPublishWhenRemoteEmpty: Boolean = false, + forceRefreshLightning: 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, + forceRefreshLightning = forceRefreshLightning, + ).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 = PrivatePaykitErrorClassifier.shouldRetryLinkEstablishmentFailure(it) + if (scheduleRetries && shouldRetry) schedulePendingPublicationRetry(publicKey) + Logger.debug( + if (shouldRetry) { + "Deferred private Paykit endpoint publish for '$redactedKey'" + } else { + "Skipped private Paykit endpoint publish for '$redactedKey'" + }, + 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 (PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(it)) null else 0 + }, + ) + + private suspend fun publishLocalEndpointsBestEffort( + publicKey: String, + linkId: String, + fetchedRemoteCount: Int, + context: String, + generation: Long = currentStateGeneration(), + respectInitialPublishDelay: Boolean = true, + forceRefreshLightning: Boolean = false, + ) { + if (!canPublishPrivateEndpoints()) return + if (!shouldPublishLocalEndpoints(publicKey, fetchedRemoteCount)) return + if (respectInitialPublishDelay && shouldDeferInitialLocalPublish(publicKey, fetchedRemoteCount)) return + + publishLocalEndpoints( + publicKey = publicKey, + linkId = linkId, + generation = generation, + forceRefreshLightning = forceRefreshLightning, + ).onFailure { + schedulePendingPublicationRetry(publicKey) + 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(), + forceRefreshLightning: Boolean = false, + ): Result = withContext(serializedDispatcher) { + runCatching { + publicationMutex.withLock { + ensureCurrentGeneration(generation) + if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock + + val endpoints = buildLocalEndpoints(publicKey, forceRefreshLightning).getOrThrow() + ensureCurrentGeneration(generation) + 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 + ensureCurrentGeneration(generation) + if (!canPublishPrivateEndpoints() || knownSavedContact(publicKey) == null) return@withLock + + pubkyService.setPrivatePayments(linkId, entries.map { FfiPaymentEntry(it.methodId, it.endpointData) }) + ensureCurrentGeneration(generation) + persistLinkSnapshot(linkId, publicKey, linkWasReplaced = false, generation = generation).getOrThrow() + contactState.lastLocalPayloadHash = payloadHash + persistState(markWalletBackup = false) + } + }.onFailure { + recordLinkFailure(publicKey, it, generation) + } + } + + private suspend fun buildLocalEndpoints( + publicKey: String, + forceRefreshLightning: Boolean = false, + ): 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, forceRefresh = forceRefreshLightning).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, + forceRefresh: Boolean = false, + ): Result = + withContext(serializedDispatcher) { + runCatching { + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + + val bolt11 = lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(), + ).getOrThrow() + if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runCatching it } + + val decoded = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice + ?: throw PublicPaykitError.InvalidPayload + 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 (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) throw error + + val restoredLinkId = restoreLinkHandleForReadRetry(publicKey, generation).getOrNull() + ?: throw error + + Logger.info( + "Retrying private Paykit endpoint fetch for '${redacted(publicKey)}'", + context = TAG, + ) + readRemoteEndpoints(publicKey, restoredLinkId, generation).getOrElse { + throw it + } + } + }.onFailure { + recordLinkFailure(publicKey, it, generation) + } + } + + private suspend fun readRemoteEndpoints( + publicKey: String, + linkId: String, + generation: Long, + ): Result = + withContext(serializedDispatcher) { + runCatching { + ensureCurrentGeneration(generation) + val 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 = recoveryStore.freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_INIT), + ) + if (remoteRecoveryMarker != null && shouldReplaceUsableLink(remoteRecoveryMarker, normalizedKey)) { + if (!discardLinkForRecovery(normalizedKey, linkId, remoteRecoveryMarker.createdAt)) return null + } else { + return linkId + } + } + + contactState.linkSnapshotHex?.let { snapshot -> + val shouldRestoreSnapshot = runCatching { + snapshotRecipientMatches(snapshot, normalizedKey, pubkyService::encryptedLinkSnapshotRecipient) + }.getOrElse { + Logger.warn( + "Failed to inspect private Paykit link snapshot for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + 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, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_INIT), + ) + if (remoteRecoveryMarker != null && shouldReplaceUsableLink(remoteRecoveryMarker, normalizedKey)) { + val didDiscard = discardLinkForRecovery( + publicKey = normalizedKey, + linkId = restoredLinkId, + startedAt = remoteRecoveryMarker.createdAt, + ) + if (!didDiscard) return null + } else { + return restoredLinkId + } + } + } + + val isRecovering = shouldStartRecoveryHandshake(normalizedKey) + val fetchedRemoteRecoveryInitMarker = recoveryStore.freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_INIT), + ) + val remoteRecoveryInitMarker = fetchedRemoteRecoveryInitMarker + ?.takeUnless { isCompletedRecoveryMarker(it, normalizedKey) } + val remoteRecoveryFinalForResponder = contactState.responderRecoveryAttemptId?.let { + recoveryStore.freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_FINAL), + attemptId = it, + ) + } + val remoteRecoveryMarker = remoteRecoveryInitMarker ?: remoteRecoveryFinalForResponder + + val initialMainRecoveryAttemptId = contactState.mainRecoveryAttemptId + val localMainRecoveryMarker = initialMainRecoveryAttemptId?.let { + recoveryStore.freshRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stages = setOf(RECOVERY_MARKER_STAGE_INIT, RECOVERY_MARKER_STAGE_FINAL), + attemptId = it, + ) + } + val shouldAcceptRemoteRecovery = if (remoteRecoveryFinalForResponder != null) { + true + } else { + remoteRecoveryMarker?.let { + shouldAcceptRemoteRecoveryMarker( + remoteMarker = it, + localMarker = localMainRecoveryMarker, + ownPublicKey = ownPublicKey, + remotePublicKey = normalizedKey, + ) + } ?: false + } + + if (shouldAcceptRemoteRecovery && remoteRecoveryMarker != null) { + val isNewResponderAttempt = contactState.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId + if (isNewResponderAttempt) { + if (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery responder")) return null + ensureCurrentGeneration(generation) + activeHandlesByContact[normalizedKey]?.handshakeId?.let { + runCatching { pubkyService.dropEncryptedLinkHandshake(it) } + } + activeHandlesByContact[normalizedKey] = ContactPaykitHandles() + contactState.handshakeSnapshotHex = null + contactState.mainRecoveryAttemptId = null + contactState.responderRecoveryAttemptId = remoteRecoveryMarker.attemptId + contactState.recoveryStartedAt = remoteRecoveryMarker.createdAt + contactState.lastLocalPayloadHash = null + contactState.remoteEndpoints = emptyList() + persistState(markWalletBackup = true) + } + recoveryStore.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 (!recoveryStore.purgePrivatePaymentOutbox(normalizedKey, "recovery initiator")) return null + ensureCurrentGeneration(generation) + activeHandlesByContact[normalizedKey]?.handshakeId?.let { + runCatching { pubkyService.dropEncryptedLinkHandshake(it) } + } + activeHandlesByContact[normalizedKey] = ContactPaykitHandles() + val attemptId = UUID.randomUUID().toString() + val createdAt = clock.now().epochSeconds + contactState.handshakeSnapshotHex = null + contactState.mainRecoveryAttemptId = attemptId + contactState.responderRecoveryAttemptId = null + contactState.recoveryStartedAt = createdAt + contactState.lastLocalPayloadHash = null + contactState.remoteEndpoints = emptyList() + persistState(markWalletBackup = true) + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = attemptId, + createdAt = createdAt, + ) + } + + if ( + shouldInitiateRecovery && + initialMainRecoveryAttemptId != null && + contactState.mainRecoveryAttemptId != null && + localMainRecoveryMarker == null + ) { + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = checkNotNull(contactState.mainRecoveryAttemptId), + createdAt = clock.now().epochSeconds, + ) + } + + if (isRecovering && !shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { + contactState.responderRecoveryAttemptId = null + persistState(markWalletBackup = true) + } + + if ( + shouldInitiateRecovery && + contactState.mainRecoveryAttemptId != null && + contactState.handshakeSnapshotHex != null + ) { + val attemptId = checkNotNull(contactState.mainRecoveryAttemptId) + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = attemptId, + createdAt = clock.now().epochSeconds, + ) + val hasPeerProgress = recoveryStore.freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_RESPONSE, RECOVERY_MARKER_STAGE_FINAL), + attemptId = attemptId, + ) != null + if (!hasPeerProgress) return null + } + + if ( + shouldAcceptRemoteRecovery && + contactState.responderRecoveryAttemptId != null && + contactState.handshakeSnapshotHex != null + ) { + val attemptId = checkNotNull(contactState.responderRecoveryAttemptId) + val hasPeerFinal = recoveryStore.freshRecoveryMarker( + from = normalizedKey, + to = ownPublicKey, + stages = setOf(RECOVERY_MARKER_STAGE_FINAL), + attemptId = attemptId, + ) != null + if (!hasPeerFinal) { + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_RESPONSE, + attemptId = attemptId, + createdAt = clock.now().epochSeconds, + ) + return null + } + } + + var handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId + if (handshakeId == null) { + contactState.handshakeSnapshotHex?.let { snapshot -> + val shouldRestoreSnapshot = runCatching { + snapshotRecipientMatches( + snapshotHex = snapshot, + publicKey = normalizedKey, + recipient = pubkyService::encryptedLinkHandshakeSnapshotRecipient, + ) + }.getOrElse { + Logger.warn( + "Failed to inspect private Paykit handshake snapshot for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + clearInvalidHandshakeSnapshotState(contactState) + false + } + + if (!shouldRestoreSnapshot) { + if (contactState.handshakeSnapshotHex != null) { + Logger.warn( + "Dropped private Paykit handshake snapshot with mismatched recipient for " + + "'${redacted(normalizedKey)}'", + context = TAG, + ) + clearInvalidHandshakeSnapshotState(contactState) + } + return@let + } + + runCatching { + handshakeId = pubkyService.restoreEncryptedLinkHandshake(secretKeyHex, snapshot) + ensureCurrentGeneration(generation) + }.onFailure { + if (it is PrivatePaykitError.PrivateUnavailable) throw it + Logger.warn( + "Failed to restore private Paykit handshake for '${redacted(normalizedKey)}'", + it, + context = TAG, + ) + contactState.handshakeSnapshotHex = null + contactState.mainRecoveryAttemptId = null + persistState(markWalletBackup = true) + } + } + } + + if (handshakeId == null) { + val shouldInitiate = shouldInitiateRecovery || + (!shouldAcceptRemoteRecovery && shouldInitiate(ownPublicKey, normalizedKey)) + handshakeId = if (shouldInitiate) { + pubkyService.initiateEncryptedLink(secretKeyHex, normalizedKey) + } else { + pubkyService.acceptEncryptedLink(secretKeyHex, normalizedKey) + } + ensureCurrentGeneration(generation) + if (isRecovering) { + contactState.recoveryStartedAt = clock.now().epochSeconds + persistState(markWalletBackup = true) + } + } + + val isRecoveryHandshake = shouldInitiateRecovery || shouldAcceptRemoteRecovery + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) + repeat(maxAdvanceSteps) { + val progress = runCatching { pubkyService.advanceHandshake(checkNotNull(handshakeId)) } + .getOrElse { + if (PrivatePaykitErrorClassifier.isEncryptedHandshakePendingError(it)) { + val snapshot = pubkyService.serializeEncryptedLinkHandshake(checkNotNull(handshakeId)) + ensureCurrentGeneration(generation) + contactState.handshakeSnapshotHex = snapshot + contactState.handshakeUpdatedAt = clock.now().epochSeconds + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) + persistState(markWalletBackup = true) + return null + } + if (PrivatePaykitErrorClassifier.isEncryptedHandshakeStateFailure(it)) { + ensureCurrentGeneration(generation) + activeHandlesByContact[normalizedKey] = ContactPaykitHandles() + contactState.handshakeSnapshotHex = null + contactState.mainRecoveryAttemptId = null + persistState(markWalletBackup = true) + } + throw it + } + ensureCurrentGeneration(generation) + + if (progress.status == HANDSHAKE_COMPLETE) { + val linkId = progress.handleId + val attemptId = contactState.mainRecoveryAttemptId ?: contactState.responderRecoveryAttemptId + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId = linkId) + contactState.handshakeSnapshotHex = null + contactState.recoveryStartedAt = null + persistLinkSnapshot( + linkId = linkId, + publicKey = normalizedKey, + linkWasReplaced = true, + generation = generation, + ).getOrThrow() + if (isRecoveryHandshake && attemptId != null) { + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_FINAL, + attemptId = attemptId, + createdAt = clock.now().epochSeconds, + ) + } + return linkId + } + + handshakeId = progress.handleId + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(handshakeId = handshakeId) + contactState.handshakeSnapshotHex = + pubkyService.serializeEncryptedLinkHandshake(checkNotNull(handshakeId)) + ensureCurrentGeneration(generation) + contactState.handshakeUpdatedAt = clock.now().epochSeconds + persistState(markWalletBackup = true) + + if (isRecoveryHandshake) { + val createdAt = clock.now().epochSeconds + if (shouldInitiateRecovery && contactState.mainRecoveryAttemptId != null) { + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_INIT, + attemptId = checkNotNull(contactState.mainRecoveryAttemptId), + createdAt = createdAt, + ) + } else if (shouldAcceptRemoteRecovery && contactState.responderRecoveryAttemptId != null) { + recoveryStore.publishRecoveryMarker( + from = ownPublicKey, + to = normalizedKey, + stage = RECOVERY_MARKER_STAGE_RESPONSE, + attemptId = checkNotNull(contactState.responderRecoveryAttemptId), + createdAt = createdAt, + ) + } + return null + } + } + + 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 = PrivatePaykitPayloads.privateEndpointRemovalEntries() + PrivatePaykitPayloads.validateNoisePayload(entries) + pubkyService.setPrivatePayments( + linkId, + entries.map { FfiPaymentEntry(it.methodId, it.endpointData) }, + ) + ensureCurrentGeneration(generation) + ensureState().contacts[publicKey]?.lastLocalPayloadHash = null + ensureState().contacts[publicKey]?.localInvoice = null + persistLinkSnapshot( + linkId = linkId, + publicKey = publicKey, + linkWasReplaced = false, + generation = generation, + ).getOrThrow() + pubkyService.currentPublicKey() + ?.let { PubkyPublicKeyFormat.normalized(it) } + ?.let { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } + } + } + 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 { recoveryStore.clearRecoveryMarker(from = it, to = publicKey) } + activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } + activeHandlesByContact[publicKey]?.handshakeId?.let { + runCatching { pubkyService.dropEncryptedLinkHandshake(it) } + } + activeHandlesByContact.remove(publicKey) + ensureState().contacts.remove(publicKey) + persistState(markWalletBackup = true) + } + + private suspend fun 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 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 = stateStore.currentState()?.contacts?.get(publicKey) ?: return false + return contactState.linkSnapshotHex != null || + contactState.lastLocalPayloadHash != null || + contactState.localInvoice != null || + contactState.linkCompletedAt != null || + contactState.recoveryStartedAt != null + } + + private suspend fun shouldPublishLocalEndpoints(publicKey: String, fetchedRemoteCount: Int): Boolean { + val contactState = ensureState().contacts[publicKey] + if (contactState?.lastLocalPayloadHash != null) return true + if (fetchedRemoteCount > 0 || contactState?.remoteEndpoints?.isNotEmpty() == true) return true + val ownPublicKey = pubkyService.currentPublicKey() ?: return false + return shouldInitiate(ownPublicKey, publicKey) + } + + private suspend fun contactStateShouldPublishBeforeFetch(publicKey: String): Boolean { + if (!shouldPublishLocalEndpoints(publicKey, fetchedRemoteCount = 0)) return false + return !shouldDeferInitialLocalPublish(publicKey, fetchedRemoteCount = 0) + } + + private suspend fun shouldDeferInitialLocalPublish(publicKey: String, fetchedRemoteCount: Int): Boolean { + val contactState = ensureState().contacts[publicKey] ?: return false + val linkCompletedAt = contactState.linkCompletedAt ?: return false + return fetchedRemoteCount == 0 && + contactState.lastLocalPayloadHash == null && + contactState.remoteEndpoints.isEmpty() && + clock.now().epochSeconds <= linkCompletedAt + FRESH_LINK_INITIAL_PUBLISH_DELAY_SECONDS + } + + @Suppress("ReturnCount") + private suspend fun shouldStartRecoveryHandshake(publicKey: String): Boolean { + val contactState = ensureState().contacts[publicKey] ?: return false + if (contactState.linkSnapshotHex != null) return false + if (contactState.recoveryStartedAt != null || contactState.mainRecoveryAttemptId != null) return true + if (contactState.handshakeSnapshotHex != null) return false + if (contactState.linkCompletedAt != null || contactState.handshakeUpdatedAt != null) return true + return addressReservationRepo.hasContactAssignment(publicKey) + } + + private suspend fun discardLinkForRecovery(publicKey: String, linkId: String?, startedAt: Long): Boolean { + linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } + activeHandlesByContact[publicKey] = ContactPaykitHandles() + ensureState().contacts[publicKey]?.apply { + linkSnapshotHex = null + handshakeSnapshotHex = null + lastLocalPayloadHash = null + remoteEndpoints = emptyList() + recoveryStartedAt = startedAt + mainRecoveryAttemptId = null + responderRecoveryAttemptId = null + } + persistState(markWalletBackup = true) + return true + } + + private fun shouldAcceptRemoteRecoveryMarker( + remoteMarker: RecoveryMarker, + localMarker: RecoveryMarker?, + ownPublicKey: String, + remotePublicKey: String, + ): Boolean { + if (localMarker == null) return true + if (remoteMarker.createdAt != localMarker.createdAt) return remoteMarker.createdAt < localMarker.createdAt + if (remoteMarker.attemptId != localMarker.attemptId) return remoteMarker.attemptId < localMarker.attemptId + return remotePublicKey < ownPublicKey + } + + private fun isCompletedRecoveryMarker(marker: RecoveryMarker, publicKey: String): Boolean = + stateStore.currentState()?.contacts?.get(publicKey)?.lastCompletedRecoveryAttemptId == marker.attemptId + + private fun shouldReplaceUsableLink(marker: RecoveryMarker, publicKey: String): Boolean { + if (isCompletedRecoveryMarker(marker, publicKey)) return false + val linkCompletedAt = stateStore.currentState()?.contacts?.get(publicKey)?.linkCompletedAt ?: return true + return marker.createdAt > linkCompletedAt + COMPLETED_LINK_RECOVERY_MARKER_GRACE_SECONDS + } + + 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 (!PrivatePaykitErrorClassifier.shouldCountAsStaleLinkFailure(error)) return + val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } + contactState.linkFailureCount += 1 + if (contactState.linkFailureCount < STALE_LINK_FAILURE_THRESHOLD) { + persistState() + return + } + + advanceStateGeneration() + activeHandlesByContact[publicKey]?.linkId?.let { runCatching { pubkyService.closeEncryptedLink(it) } } + activeHandlesByContact[publicKey] = ContactPaykitHandles() + contactState.linkSnapshotHex = null + contactState.handshakeSnapshotHex = null + contactState.lastLocalPayloadHash = null + contactState.remoteEndpoints = emptyList() + contactState.linkFailureCount = 0 + contactState.recoveryStartedAt = clock.now().epochSeconds + contactState.mainRecoveryAttemptId = null + contactState.responderRecoveryAttemptId = null + persistState(markWalletBackup = true) + } + + private suspend fun recordLinkSuccess(publicKey: String) { + val contactState = ensureState().contacts[publicKey] ?: return + if (contactState.linkFailureCount == 0) return + contactState.linkFailureCount = 0 + persistState() + } + + private fun clearInvalidLinkSnapshotState(contactState: ContactState) { + contactState.linkSnapshotHex = null + contactState.handshakeSnapshotHex = null + contactState.remoteEndpoints = emptyList() + contactState.lastLocalPayloadHash = null + contactState.linkCompletedAt = null + contactState.handshakeUpdatedAt = null + contactState.recoveryStartedAt = null + contactState.mainRecoveryAttemptId = null + contactState.responderRecoveryAttemptId = null + contactState.linkFailureCount = 0 + } + + private fun clearInvalidHandshakeSnapshotState(contactState: ContactState) { + contactState.handshakeSnapshotHex = null + contactState.lastLocalPayloadHash = null + contactState.handshakeUpdatedAt = null + contactState.recoveryStartedAt = null + contactState.mainRecoveryAttemptId = null + contactState.responderRecoveryAttemptId = null + contactState.linkFailureCount = 0 + } + + private suspend fun validatedSnapshot( + snapshotHex: String?, + publicKey: String, + recipient: suspend (String) -> String, + ): String? { + if (snapshotHex == null) return null + return runCatching { + validateSnapshot(snapshotHex, publicKey, recipient) + snapshotHex + }.onFailure { + Logger.warn( + "Dropped private Paykit snapshot with mismatched recipient for '${redacted(publicKey)}'", + it, + context = TAG, + ) + }.getOrNull() + } + + private suspend fun validateSnapshot( + snapshotHex: String, + publicKey: String, + recipient: suspend (String) -> String, + ) { + if (!snapshotRecipientMatches(snapshotHex, publicKey, recipient)) { + throw PrivatePaykitError.SnapshotRecipientMismatch + } + } + + private suspend fun snapshotRecipientMatches( + snapshotHex: String, + publicKey: String, + recipient: suspend (String) -> String, + ): Boolean { + val snapshotRecipient = recipient(snapshotHex) + return PubkyPublicKeyFormat.normalized(snapshotRecipient) == PubkyPublicKeyFormat.normalized(publicKey) + } + + private fun rememberSavedContacts(publicKeys: Collection, replacing: Boolean): List { + val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() + if (replacing) { + knownSavedContactKeys.clear() + knownSavedContactKeys += normalizedKeys + } else { + knownSavedContactKeys += normalizedKeys + } + 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 suspend fun ensureState(): PrivatePaykitState = stateStore.ensureState() + + private suspend fun persistState( + markWalletBackup: Boolean = false, + preserveCleanupMarkers: Boolean = true, + ) { + stateStore.persistState(markWalletBackup, ::notifyBackupStateChanged, preserveCleanupMarkers) + } + + private fun notifyBackupStateChanged() { + _backupStateVersion.update { it + 1 } + } +} 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 000000000..61553a64b --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitStateStore.kt @@ -0,0 +1,74 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.first +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.json +import to.bitkit.utils.Logger + +internal class PrivatePaykitStateStore( + private val keychain: Keychain, + private val cacheStore: PrivatePaykitCacheStore, +) { + companion object { + private const val TAG = "PrivatePaykitStateStore" + } + + private var state: PrivatePaykitState? = null + + fun currentState(): PrivatePaykitState? = state + + fun replaceState(newState: PrivatePaykitState) { + state = newState + } + + suspend fun ensureState(): PrivatePaykitState { + state?.let { return it } + val serializedSecretState = runCatching { + keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) + }.onFailure { + Logger.warn("Failed to load private Paykit secret state", it, context = TAG) + }.getOrNull() + val secretState = serializedSecretState + ?.let { serialized -> + runCatching { + json.decodeFromString(serialized) + }.onFailure { + Logger.warn("Failed to decode private Paykit secret state", it, context = TAG) + }.getOrNull() + } ?: PrivatePaykitSecretState() + val cacheState = cacheStore.data.first() + + return PrivatePaykitState(secretState, cacheState).also { state = it } + } + + suspend fun persistState( + markWalletBackup: Boolean, + notifyBackupStateChanged: () -> Unit, + preserveCleanupMarkers: Boolean = true, + ) { + val current = state ?: return + runCatching { + val secretState = current.secretState() + if (secretState.contacts.isEmpty()) { + keychain.delete(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name) + } else { + keychain.upsertString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name, json.encodeToString(secretState)) + } + + cacheStore.update { stored -> + current.cacheState( + cleanupPending = if (preserveCleanupMarkers) stored.cleanupPending else false, + deletedContactCleanupPendingPublicKeys = if (preserveCleanupMarkers) { + stored.deletedContactCleanupPendingPublicKeys + } else { + emptySet() + }, + ) + } + if (markWalletBackup) notifyBackupStateChanged() + }.getOrElse { throw PrivatePaykitError.StatePersistenceFailed(it) } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index f2ae26db5..1b666780b 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 d8de750a6..75ce1fb42 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) { @@ -165,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) } } @@ -235,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", @@ -243,10 +255,11 @@ class PublicPaykitRepo @Inject constructor( Result.success(Unit) }.getOrThrow() } + walletRepo.refreshReusableReceiveAddressIfReserved().getOrThrow() val state = walletRepo.walletState.value val endpoints = mutableListOf() - buildPublicBolt11Endpoint()?.let { endpoints += it } + buildPublicBolt11Endpoint(forceRefreshLightning)?.let { endpoints += it } val onchainAddress = state.onchainAddress if (onchainAddress.isNotBlank()) { @@ -263,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 @@ -271,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/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index dde2ee0a4..58fb3fbb0 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)) { + replaceReusableOnchainAddress() } else { checkAddressUsage(address).onSuccess { wasUsed -> if (wasUsed) { @@ -347,9 +348,33 @@ 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 + } + + clearReusableOnchainAddress() + newAddress().getOrThrow() + updateBip21Url() + }.onFailure { + Logger.error("Failed to refresh reserved receive address", it, context = TAG) + } + } + + 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) { @@ -371,31 +396,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, - isChange = isChange, - ).substringBeforeLast("/0") - - val result = coreService.onchain.deriveBitcoinAddresses( - mnemonicPhrase = mnemonic, - derivationPathStr = baseDerivationPath, - network = Env.network, - bip39Passphrase = passphrase, + val result = lightningRepo.addressInfosForType( + addressType = addressType, 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 8baa73bb5..a9a8cd23d 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,13 +77,19 @@ 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.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 @@ -98,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 @@ -107,6 +115,7 @@ class CoreService @Inject constructor( cacheStore = cacheStore, lightningService = lightningService, settingsStore = settingsStore, + privatePaykitContactResolver = privatePaykitContactResolver, ) } val blocktank: BlocktankService by lazy { @@ -214,6 +223,7 @@ class ActivityService( private val cacheStore: CacheStore, private val lightningService: LightningService, private val settingsStore: SettingsStore, + private val privatePaykitContactResolver: Provider, ) { suspend fun removeAll() { ServiceQueue.CORE.background { @@ -394,7 +404,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 +479,8 @@ class ActivityService( } } - private fun processBolt11( + @Suppress("CyclomaticComplexMethod") + private suspend fun processBolt11( kind: PaymentKind.Bolt11, payment: PaymentDetails, state: PaymentState, @@ -483,17 +494,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 +528,7 @@ class ActivityService( fee = msatFloorOf(payment.feePaidMsat ?: 0u), message = kind.description.orEmpty(), preimage = kind.preimage, + contact = contact, seenAt = null, ) } @@ -524,7 +547,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 +572,191 @@ 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 } + val currentWalletAddress = cacheStore.data.first().onchainAddress + val selectedAddressType = settingsStore.data.first().selectedAddressType.toAddressType() ?: DEFAULT_ADDRESS_TYPE + return findAddressInPreActivityMetadata(details) + ?: searchReceivingAddressWithLdk( + details = details, + value = payment.amountSats ?: 0u, + currentWalletAddress = currentWalletAddress, + selectedAddressType = selectedAddressType, + ) + ?: 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 +781,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 +817,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 +826,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 +857,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 +880,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 +902,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 +927,7 @@ class ActivityService( kind = kind, confirmationData = confirmationData, resolvedAddress = resolvedAddress, + contact = contact, channelId = resolvedChannelId, ) } @@ -738,6 +948,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 privatePaykitContactResolver.get().contactPublicKeyForPrivateInvoicePaymentHash(paymentHash) + } + + private suspend fun privatePaykitContactPublicKeyForReservedAddress(address: String): String? = + privatePaykitContactResolver.get().contactPublicKeyForPrivateOnchainAddresses(listOf(address)) + // MARK: - Test Data Generation (regtest only) @Suppress("LongMethod") @@ -874,7 +1095,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 +1126,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 +1201,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 +1590,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 979bb6459..edea8b497 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 702e09ed4..8658336f4 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 4b2d36b74..1d0b09833 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 1ce949906..08caea71a 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, + showPayButton = uiState.showPayButton, onClickEdit = onClickEdit, onClickCopy = onClickCopy, onClickPay = onClickPay, @@ -136,7 +136,7 @@ private fun Content( private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, - hasPublicPaymentEndpoint: 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 (hasPublicPaymentEndpoint) { + 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 247cb763a..dd15c57f5 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, + showPayButton = 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, + showPayButton = 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 showPayButton: 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 c511627fc..eaf7bb7d2 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 41cc7fbb8..248c03403 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,10 +18,11 @@ 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 -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -29,11 +30,9 @@ 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" - } - private val _uiState = MutableStateFlow(PayContactsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -58,23 +57,23 @@ 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) } .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), @@ -90,6 +89,68 @@ class PayContactsViewModel @Inject constructor( } } + private suspend fun enableContactPayments(contacts: List): Result { + 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( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + } + }.onFailure { + return Result.failure(it) + } + + privatePaykitRepo.prepareSavedContacts(contacts) + + 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 } + + privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts) + .onFailure { + if (cleanupError == null) cleanupError = it + } + + cleanupError?.let { + privatePaykitRepo.setContactSharingCleanupPending(true) + .onFailure { markerError -> + it.addSuppressed(markerError) + return Result.failure(it) + } + 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 b6fa2c6f9..0408d847e 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 f0833dcf9..7f4a9d521 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,6 +27,7 @@ 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 javax.inject.Inject @@ -38,8 +39,8 @@ class AddressTypePreferenceViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, + private val privatePaykitRepo: PrivatePaykitRepo, ) : ViewModel() { - private val _uiState = MutableStateFlow(AddressTypePreferenceUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -83,6 +84,7 @@ class AddressTypePreferenceViewModel @Inject constructor( monitoredTypes = currentMonitored.toList(), ).onSuccess { walletRepo.refreshReceiveAddressAfterTypeChange() + privatePaykitRepo.refreshKnownSavedContactEndpoints("address type changed") } _uiState.update { it.copy(isLoading = false) } @@ -122,6 +124,9 @@ class AddressTypePreferenceViewModel @Inject constructor( ) val repoResult = lightningRepo.setMonitoring(addressType, enabled) + .onSuccess { + privatePaykitRepo.refreshKnownSavedContactEndpoints("address monitoring changed") + } _uiState.update { it.copy(isLoading = false) } loadState() 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 acc5c6562..ec01d8952 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 a64d1ef98..cff4d36ac 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/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index d54b7631c..4851adc8c 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) }, ) } diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index ca1f462ec..bff60f413 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 2e95d70d6..9c0237c17 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 @@ -339,8 +343,10 @@ class AppViewModel @Inject constructor( } } observeLdkNodeEvents() + observeLightningUsableChannels() observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() + observePrivatePaykitContacts() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -377,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 { @@ -408,17 +432,79 @@ class AppViewModel @Inject constructor( viewModelScope.launch { refreshPublicPaykitEndpointsIfEnabled() } } - private suspend fun refreshPublicPaykitEndpointsIfEnabled() { + fun refreshPrivatePaykitEndpoints() { + viewModelScope.launch { refreshPrivatePaykitEndpointsIfEnabled("foreground") } + } + + 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) } } + 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, + forceRefreshLightning: Boolean = false, + ) { + 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, forceRefreshLightning = forceRefreshLightning) + } + @Suppress("CyclomaticComplexMethod") private fun handleLdkEvent(event: Event) { if (!walletRepo.walletExists()) return @@ -461,10 +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() - notifyChannelReady(event) + refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning = forceRefreshLightning) + refreshPrivatePaykitEndpointsIfEnabled(reason, forceRefreshLightning = forceRefreshLightning) } private suspend fun handleChannelPending() = transferRepo.syncTransferStates() @@ -481,6 +580,7 @@ class AppViewModel @Inject constructor( transferRepo.syncTransferStates() walletRepo.syncBalances() refreshPublicPaykitEndpointsIfEnabled() + refreshPrivatePaykitEndpointsIfEnabled("channel closed") } private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { @@ -538,6 +638,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 +777,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 +811,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) } @@ -701,13 +831,30 @@ 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) + privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> + activityRepo.setContact( + contactPublicKey = publicKey, + forPaymentId = paymentHash, + syncLdkPayments = false, + ) + } publicPaykitRepo.refreshPublishedBolt11ForPayment(paymentHash) .onFailure { Logger.warn( @@ -716,6 +863,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 +1649,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 +2157,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 +2176,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 +2195,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 +2214,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 +2226,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 +2842,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 +2963,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) } } @@ -2802,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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 790513476..74cfb418b 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/ActivityDetailViewModelTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt index 7240d6dd7..9a3311853 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 8cbdcdf19..d05b3b4f2 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 109806447..b1903f9db 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 000000000..60a492cd0 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -0,0 +1,252 @@ +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.assertFalse +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 `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( + 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/PrivatePaykitContactResolverTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitContactResolverTest.kt new file mode 100644 index 000000000..6fc30b101 --- /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 new file mode 100644 index 000000000..b71330941 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -0,0 +1,789 @@ +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 + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val CONTACT_KEY = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val OWN_KEY = "pubkyeytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + private const val SECRET_KEY_HEX = "secret" + private const val LINK_ID = "link-id" + private const val HANDSHAKE_ID = "handshake-id" + private const val LINK_SNAPSHOT = "link-snapshot" + private const val UPDATED_LINK_SNAPSHOT = "updated-link-snapshot" + private const val HANDSHAKE_SNAPSHOT = "handshake-snapshot" + private const val UPDATED_HANDSHAKE_SNAPSHOT = "updated-handshake-snapshot" + private const val LOCAL_PAYLOAD_HASH = "local-payload-hash" + private const val 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 { 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() + } + + @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 `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() + 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 `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() + 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 clears mismatched link snapshot and starts fresh handshake`() = test { + startForegroundWithSharingEnabled() + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(OWN_KEY) + stubPendingFreshHandshake() + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + verify(pubkyService, never()).restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT) + verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) + val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + assertNotNull(snapshot) + assertNull(snapshot.linkSnapshotHex) + assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) + } + + @Test + fun `prepareSavedContacts clears mismatched handshake snapshot and starts fresh handshake`() = test { + startForegroundWithSharingEnabled() + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson(linkSnapshotHex = null, handshakeSnapshotHex = HANDSHAKE_SNAPSHOT)) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.fetchFileString(any())).thenAnswer { throw PrivatePaykitTestError("not found") } + whenever(pubkyService.encryptedLinkHandshakeSnapshotRecipient(HANDSHAKE_SNAPSHOT)).thenReturn(OWN_KEY) + stubPendingFreshHandshake() + + sut.prepareSavedContacts(listOf(CONTACT_KEY)).getOrThrow() + + verify(pubkyService, never()).restoreEncryptedLinkHandshake(SECRET_KEY_HEX, HANDSHAKE_SNAPSHOT) + verify(pubkyService).initiateEncryptedLink(SECRET_KEY_HEX, CONTACT_KEY) + val snapshot = sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY) + assertNotNull(snapshot) + assertEquals(UPDATED_HANDSHAKE_SNAPSHOT, snapshot.handshakeSnapshotHex) + } + + @Test + fun `prepareSavedContacts publishes after fetching empty remote endpoints for fresh initiator link`() = test { + startForegroundWithSharingEnabled() + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to PrivatePaykitContactCacheData( + linkCompletedAt = NOW_SECONDS, + ), + ), + ) + whenever(keychain.loadString(Keychain.Key.PRIVATE_PAYKIT_SECRET_STATE.name)) + .thenReturn(secretStateJson()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX) + whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever(pubkyService.encryptedLinkSnapshotRecipient(LINK_SNAPSHOT)).thenReturn(CONTACT_KEY) + whenever(pubkyService.restoreEncryptedLink(SECRET_KEY_HEX, LINK_SNAPSHOT)).thenReturn(LINK_ID) + whenever(pubkyService.getPrivatePayments(LINK_ID)).thenReturn(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 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) + 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 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) + 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( + linkSnapshotHex: String? = LINK_SNAPSHOT, + handshakeSnapshotHex: String? = null, + ): String { + val linkSnapshot = linkSnapshotHex?.let { "\"$it\"" } ?: "null" + val handshakeSnapshot = handshakeSnapshotHex?.let { "\"$it\"" } ?: "null" + return """ + {"contacts":{"$CONTACT_KEY":{"linkSnapshotHex":$linkSnapshot,"handshakeSnapshotHex":$handshakeSnapshot}}} + """.trimIndent() + } +} + +private class PrivatePaykitTestError( + message: String, + cause: Throwable? = null, +) : AppError(message, cause) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4140a80b6..a652ba37f 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() @@ -81,19 +80,21 @@ 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())) 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 +103,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 +127,7 @@ class WalletRepoTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, deriveBalanceStateUseCase = deriveBalanceStateUseCase, wipeWalletUseCase = wipeWalletUseCase, + privatePaykitAddressReservationRepo = privatePaykitAddressReservationRepo, transferRepo = transferRepo, activityRepo = activityRepo, ) @@ -225,24 +220,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 +248,7 @@ class WalletRepoTest : BaseUnitTest() { val result = sut.refreshBip21() assertTrue(result.isSuccess) - verify(lightningRepo, never()).newAddress() + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() } @Test @@ -338,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) @@ -584,7 +611,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 +623,7 @@ class WalletRepoTest : BaseUnitTest() { ) ) - verify(lightningRepo).newAddress() + verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() } @Test @@ -616,7 +642,7 @@ class WalletRepoTest : BaseUnitTest() { ) ) - verify(lightningRepo, never()).newAddress() + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() } @Test @@ -639,36 +665,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 000000000..e77bf24ae --- /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 24cc33e71..57cee1316 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 000000000..eba7c5dd1 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt @@ -0,0 +1,199 @@ +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.disableSharingAndPruneUnsavedContactState(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()).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 + 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).disableSharingAndPruneUnsavedContactState(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.disableSharingAndPruneUnsavedContactState(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 a8beb379a..47e7573ae 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(), 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 d13ac24a5..b6ad7472f 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 99bf1e647..d2a285f31 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 @@ -28,6 +29,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 @@ -39,15 +41,18 @@ 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 +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 @@ -57,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 @@ -64,6 +70,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class AppViewModelSendFlowTest : BaseUnitTest() { private lateinit var sut: AppViewModel @@ -86,9 +93,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 +105,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 +124,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 +140,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(), any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.reconcileReservedReceiveIndexes() } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.retryPendingEndpointRemoval(any>()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(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 +200,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { migrationService = migrationService, coreService = coreService, publicPaykitRepo = publicPaykitRepo, + privatePaykitRepo = privatePaykitRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), @@ -359,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" @@ -435,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() @@ -451,7 +685,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = true) } @Test @@ -470,7 +705,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + verify(publicPaykitRepo).syncCurrentPublishedEndpoints(forceRefreshLightning = false) } @Test @@ -526,6 +761,55 @@ 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 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)) @@ -551,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") diff --git a/changelog.d/next/936.added.md b/changelog.d/next/936.added.md new file mode 100644 index 000000000..2f4fb3565 --- /dev/null +++ b/changelog.d/next/936.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 58fdeeb05..394418546 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" }