From cfe0c90615e7d57171e14d1b7b9752851cba2a52 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 09:56:16 +0200 Subject: [PATCH 1/8] feat: support incoming paykit requests --- .../to/bitkit/data/PrivatePaykitStores.kt | 3 +- .../repositories/PaykitPaymentRequestRepo.kt | 238 +++++++ .../repositories/PrivatePaykitModels.kt | 21 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 290 +++++--- .../bitkit/repositories/PublicPaykitRepo.kt | 12 +- .../to/bitkit/services/PaykitSdkService.kt | 124 +++- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 +- .../screens/contacts/AddContactViewModel.kt | 2 + .../screens/contacts/ContactDetailScreen.kt | 6 +- .../contacts/ContactDetailViewModel.kt | 18 +- .../screens/wallets/send/SendConfirmScreen.kt | 1 + .../wallets/send/SendContactSelectScreen.kt | 6 +- .../send/SendContactSelectViewModel.kt | 17 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 290 ++++++-- app/src/main/res/values/strings.xml | 1 + .../PaykitPaymentRequestRepoTest.kt | 192 ++++++ .../repositories/PrivatePaykitRepoTest.kt | 638 +++++------------- .../repositories/PublicPaykitRepoTest.kt | 9 +- .../viewmodels/AppViewModelSendFlowTest.kt | 255 ++++--- changelog.d/next/1098.added.md | 1 + gradle/libs.versions.toml | 2 +- 22 files changed, 1362 insertions(+), 775 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt create mode 100644 changelog.d/next/1098.added.md diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index ed06ddd4e3..3519b2186d 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,8 +68,7 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), - val remotePaymentListVersionsByReceiverPath: Map = emptyMap(), - val consumedPaymentListVersionsByReceiverPath: Map = emptyMap(), + val consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), val localInvoicesByReceiverPath: Map = emptyMap(), val receivedInvoicePaymentHashes: List = emptyList(), val publishedPrivatePaymentReceiverPaths: Set = emptySet(), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt new file mode 100644 index 0000000000..2b2063d437 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -0,0 +1,238 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +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.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.math.BigDecimal +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +data class PaykitPaymentRequestId( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, +) + +data class PaykitPaymentRequest( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val amountValue: String, + val amountSats: ULong, + val paymentReference: String, + val expiresAt: Instant?, + val acceptedPaymentEndpointIdentifiers: List, + val metadata: String, +) { + val id: PaykitPaymentRequestId + get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) + + fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true +} + +sealed class PaykitPaymentRequestError(message: String) : AppError(message) { + data object RequestUnavailable : PaykitPaymentRequestError("Payment request is unavailable") + data object RequestExpired : PaykitPaymentRequestError("Payment request has expired") +} + +@Singleton +class PaykitPaymentRequestRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val clock: Clock, +) { + companion object { + private const val TAG = "PaykitPaymentRequestRepo" + } + + private val operationMutex = Mutex() + private val stateGeneration = AtomicLong() + private val repoScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private var expirationJob: Job? = null + private val _pendingRequests = MutableStateFlow>(emptyList()) + val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() + + suspend fun refresh(): Result { + val generation = stateGeneration.get() + return withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + runSuspendCatching { synchronizeLocked(generation) } + .onFailure { discardExpiredRequestsLocked() } + .getOrThrow() + } + }.onFailure { + Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) + } + } + } + + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { + Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + } + + suspend fun clear() { + stateGeneration.incrementAndGet() + withContext(ioDispatcher) { + operationMutex.withLock { + expirationJob?.cancel() + expirationJob = null + _pendingRequests.update { emptyList() } + } + } + } + + private suspend fun synchronizeLocked(generation: Long) { + processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + val now = clock.now() + val requests = paykitSdkService.actionableReceivedPaymentRequests().mapNotNull { + it.toPaykitPaymentRequest(now) + } + if (stateGeneration.get() != generation) return + _pendingRequests.update { requests } + scheduleExpirationLocked() + } + + private suspend fun updateRequest( + request: PaykitPaymentRequest, + operation: suspend (PaykitPaymentRequest) -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + if (request.isExpired(clock.now())) { + discardExpiredRequestsLocked() + throw PaykitPaymentRequestError.RequestExpired + } + val current = _pendingRequests.value.firstOrNull { it.id == request.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + + operation(current) + _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } + discardExpiredRequestsLocked() + processPendingMessages() + } + } + } + + private suspend fun processPendingMessages() { + runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + .onSuccess(::logOutboundFailures) + .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } + } + + private fun logOutboundFailures(reports: List) { + reports.forEach { + val error = it.error ?: return@forEach + Logger.warn( + "Failed to deliver Paykit private messages to '${PubkyPublicKeyFormat.redacted(it.counterparty)}': " + + "'${error.redactedContext()}'", + context = TAG, + ) + } + } + + private fun logIntakeFailures(reports: List) { + reports.forEach { + val error = it.error ?: return@forEach + Logger.warn( + "Failed to receive Paykit private messages from '${PubkyPublicKeyFormat.redacted(it.counterparty)}': " + + "'${error.redactedContext()}'", + context = TAG, + ) + } + } + + private fun discardExpiredRequestsLocked() { + val now = clock.now() + _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + scheduleExpirationLocked() + } + + private fun scheduleExpirationLocked() { + expirationJob?.cancel() + expirationJob = null + + val nextExpiration = _pendingRequests.value.mapNotNull { it.expiresAt }.minOrNull() ?: return + val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) + expirationJob = repoScope.launch { + delay(delayDuration) + operationMutex.withLock { + expirationJob = null + discardExpiredRequestsLocked() + } + } + } +} + +private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") + +@Suppress("ReturnCount") +private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPaymentRequest? { + if (localRole != PaymentRequestLocalRole.PAYER || state != PaymentRequestLifecycleState.PROPOSED) return null + val requestTerms = terms ?: return null + if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null + val amountSats = requestTerms.amount.value.toSats() ?: return null + val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers + .filter { MethodId.fromRawValue(it) != null } + .distinct() + if (endpoints.isEmpty()) return null + + val expiresAt = requestTerms.proposalExpiresAt?.let { + runCatching { Instant.parse(it) }.getOrNull() ?: return null + } + if (expiresAt != null && expiresAt <= now) return null + + return PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = requestTerms.amount.value, + amountSats = amountSats, + paymentReference = requestTerms.paymentReference.exportText(), + expiresAt = expiresAt, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = requestTerms.metadata.exportText(), + ) +} + +private fun String.toSats(): ULong? { + if (!bitcoinAmountPattern.matches(this)) return null + return runCatching { + BigDecimal(this).movePointRight(8).toBigIntegerExact().toString().toULong() + }.getOrNull()?.takeIf { it > 0uL } +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 6e87e8bafd..785f26ead8 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import kotlinx.serialization.Serializable import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.PrivatePaykitStoredInvoiceData @@ -7,6 +8,8 @@ import to.bitkit.data.PrivatePaykitStoredPaymentEntryData import to.bitkit.utils.AppError sealed class PrivatePaykitError(message: String) : AppError(message) { + data object InvalidPublicKey : PrivatePaykitError("Contact public key is invalid") + data object PaymentListAlreadyConsumed : PrivatePaykitError("Private payment details are no longer available") data object PrivateUnavailable : PrivatePaykitError("Private Paykit is not available") data object RouteHintsUnavailable : PrivatePaykitError("Reachable private Lightning endpoint is not available yet") } @@ -32,16 +35,14 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), - var remotePaymentListVersionsByReceiverPath: Map = emptyMap(), - var consumedPaymentListVersionsByReceiverPath: Map = emptyMap(), + var consumedPrivatePaymentListVersionsByReceiverPath: Map = emptyMap(), var localInvoicesByReceiverPath: Map = emptyMap(), var receivedInvoicePaymentHashes: List = emptyList(), var publishedPrivatePaymentReceiverPaths: Set = emptySet(), ) { constructor(cache: PrivatePaykitContactCacheData) : this( remoteEndpoints = cache.remoteEndpoints.map { StoredPaymentEntry(it.methodId, it.endpointData) }, - remotePaymentListVersionsByReceiverPath = cache.remotePaymentListVersionsByReceiverPath, - consumedPaymentListVersionsByReceiverPath = cache.consumedPaymentListVersionsByReceiverPath, + consumedPrivatePaymentListVersionsByReceiverPath = cache.consumedPrivatePaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -52,15 +53,13 @@ internal data class ContactState( val hasCacheState: Boolean get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || remoteEndpoints.isNotEmpty() || - remotePaymentListVersionsByReceiverPath.isNotEmpty() || - consumedPaymentListVersionsByReceiverPath.isNotEmpty() || + consumedPrivatePaymentListVersionsByReceiverPath.isNotEmpty() || localInvoicesByReceiverPath.isNotEmpty() || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, - remotePaymentListVersionsByReceiverPath = remotePaymentListVersionsByReceiverPath, - consumedPaymentListVersionsByReceiverPath = consumedPaymentListVersionsByReceiverPath, + consumedPrivatePaymentListVersionsByReceiverPath = consumedPrivatePaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -74,6 +73,12 @@ internal data class StoredPaymentEntry( val endpointData: String, ) +@Serializable +internal data class PrivatePaykitBackup( + val sdkState: String, + val consumedPrivatePaymentListVersions: Map>, +) + internal data class StoredInvoice( val bolt11: String, val paymentHash: String, diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 7d82fe441d..e40aca48cc 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -2,10 +2,12 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentEndpointReservationInput import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,7 +22,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.lightningdevkit.ldknode.PaymentDirection @@ -36,7 +37,8 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService -import to.bitkit.services.PaykitPaymentEndpointSource +import to.bitkit.services.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -297,19 +299,42 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + beginContactPayment(normalizedKey, paymentRequest = null).getOrThrow() + } + } - val result = beginContactPayment(normalizedKey).getOrElse { - if (it is CancellationException) throw it - Logger.warn( - "Failed to resolve Paykit contact payment for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - return@runSuspendCatching publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() - } - result + suspend fun beginPaymentRequest(request: PaykitPaymentRequest): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + if (request.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + val publicKey = normalizedPublicKey(request.counterparty) ?: throw PrivatePaykitError.InvalidPublicKey + beginContactPayment(publicKey, request).getOrThrow() + } + }.onFailure { + Logger.warn("Failed to present incoming Paykit payment request", it, context = TAG) + } + + suspend fun consumePrivatePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } + val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] + if (consumedVersion != null && context.paymentListVersion <= consumedVersion) { + throw PrivatePaykitError.PaymentListAlreadyConsumed } + + contactState.consumedPrivatePaymentListVersionsByReceiverPath = + contactState.consumedPrivatePaymentListVersionsByReceiverPath + + (context.receiverPath to context.paymentListVersion) + contactState.remoteEndpoints = emptyList() + persistState(markWalletBackup = true) } + }.onFailure { + Logger.warn("Failed to consume private Paykit payment details", it, context = TAG) + } suspend fun discardRemoteLightningEndpoints( publicKey: String, @@ -430,15 +455,17 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching null - val backupState = PrivatePaykitBackupState( - sdkState = paykitSdkService.exportBackupState(), - consumedPaymentListVersionsByContact = ensureState().contacts.mapNotNull { (publicKey, contact) -> - contact.consumedPaymentListVersionsByReceiverPath - .takeIf { it.isNotEmpty() } - ?.let { publicKey to it } - }.toMap(), + json.encodeToString( + PrivatePaykitBackup( + sdkState = paykitSdkService.exportBackupState(), + consumedPrivatePaymentListVersions = ensureState().contacts + .mapNotNull { (publicKey, contactState) -> + contactState.consumedPrivatePaymentListVersionsByReceiverPath + .takeIf { it.isNotEmpty() } + ?.let { publicKey to it } + }.toMap(), + ) ) - BACKUP_STATE_PREFIX + json.encodeToString(backupState) } } @@ -451,90 +478,174 @@ class PrivatePaykitRepo @Inject constructor( state = PrivatePaykitState() paykitSdkService.clearState() } else { - val backupState = backup.takeIf { it.startsWith(BACKUP_STATE_PREFIX) } - ?.removePrefix(BACKUP_STATE_PREFIX) - ?.let { json.decodeFromString(it) } - state = PrivatePaykitState( - contacts = backupState?.consumedPaymentListVersionsByContact.orEmpty() - .mapValues { (_, versions) -> - ContactState(consumedPaymentListVersionsByReceiverPath = versions) - } - .toMutableMap(), - ) - paykitSdkService.restoreBackupState(backupState?.sdkState ?: backup) + val decoded = json.decodeFromString(backup) + paykitSdkService.restoreBackupState(decoded.sdkState) + decoded.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + ensureState().contacts.getOrPut(publicKey) { ContactState() } + .consumedPrivatePaymentListVersionsByReceiverPath = versions + } } persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() } } - private suspend fun beginContactPayment(publicKey: String): Result = + private suspend fun beginContactPayment( + publicKey: String, + paymentRequest: PaykitPaymentRequest?, + ): Result = withContext(serializedDispatcher) { runSuspendCatching { - pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive - if (canPublishPrivateEndpoints()) { - publishLocalEndpoints( - publicKeys = listOf(publicKey), - reason = "payment", - ).onFailure { - Logger.warn( - "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", - it, - context = TAG, - ) - } + if (!hasLiveSessionForCurrentProfile()) { + if (paymentRequest != null) throw PrivatePaykitError.PrivateUnavailable + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } - - val resolution = paykitSdkService.prepareAndResolveContactPayment( - counterparty = publicKey, - receiverPath = PaykitReceiverPaths.WALLET, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = consumedPaymentListVersion(publicKey), + if (paymentRequest == null) refreshPrivateEndpointsBeforePayment(publicKey) + + val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET + val consumedVersion = ensureState().contacts[publicKey] + ?.consumedPrivatePaymentListVersionsByReceiverPath + ?.get(receiverPath) + val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, "btc") } + val prepared = preparePrivateContactPayment( + publicKey = publicKey, + receiverPath = receiverPath, + consumedVersion = consumedVersion, + amount = amount, + allowPublicResolution = paymentRequest == null, ) - val privateEndpoints = resolution.payableEndpoints - .filter { it.source == PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST } - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + ?: if (paymentRequest == null) { + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + } else { + throw PrivatePaykitError.PrivateUnavailable + } + val resolution = prepared.resolution + val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) + if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + } - cacheResolvedPrivateEndpoints( + privatePaymentResult( publicKey = publicKey, - receiverPath = PaykitReceiverPaths.WALLET, - privatePaymentListVersion = resolution.privatePaymentListVersion, - endpoints = privateEndpoints, + receiverPath = receiverPath, + resolution = resolution, + acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), ) + } + } - val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) - if (privatePayable.isNotEmpty()) { - return@runSuspendCatching PublicPaykitPaymentResult.Opened( - PublicPaykitRepo.paymentRequest(privatePayable), - ) - } + private suspend fun refreshPrivateEndpointsBeforePayment(publicKey: String) { + if (!canPublishPrivateEndpoints()) return + publishLocalEndpoints( + publicKeys = listOf(publicKey), + reason = "payment", + ).onFailure { + Logger.warn( + "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", + it, + context = TAG, + ) + } + } - if (resolution.privateState == PrivatePaymentResolutionState.RECOVERY_PENDING) { - schedulePendingPrivateMessageDrainRetries( - reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, PaykitReceiverPaths.WALLET)), - ) - } + private suspend fun preparePrivateContactPayment( + publicKey: String, + receiverPath: String, + consumedVersion: ULong?, + amount: PaymentAmountContext?, + allowPublicResolution: Boolean, + ): PaykitPreparedPrivateContactPayment? { + val result = runSuspendCatching { + paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + receiverPath = receiverPath, + afterPrivatePaymentListVersion = consumedVersion, + amount = amount, + ) + } + val error = result.exceptionOrNull() ?: return result.getOrThrow() + if (!allowPublicResolution) throw error + if (!canUsePublicPayment(currentLinkState(publicKey, receiverPath))) throw error + + Logger.warn( + "Using public Paykit resolution for '${redacted(publicKey)}'", + error, + context = TAG, + ) + return null + } - val publicEndpoints = resolution.payableEndpoints - .filter { it.source == PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT } - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - val publicPayable = publicPaykitRepo.payableEndpoints(publicEndpoints) - if (publicPayable.isNotEmpty()) { - return@runSuspendCatching PublicPaykitPaymentResult.Opened( - PublicPaykitRepo.paymentRequest(publicPayable), - ) - } + private suspend fun privatePaymentResult( + publicKey: String, + receiverPath: String, + resolution: PaykitPrivateContactPaymentResolution, + acceptedEndpointIdentifiers: Set? = null, + ): PublicPaykitPaymentResult { + val privateEndpoints = resolution.payableEndpoints + .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + val acceptedEndpoints = privateEndpoints.filter { + acceptedEndpointIdentifiers?.contains(it.methodId.rawValue) ?: true + } + + val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) + val paymentListVersion = resolution.privatePaymentListVersion + if (privatePayable.isNotEmpty() && paymentListVersion != null) { + return PublicPaykitPaymentResult.Opened( + paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), + privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + ) + } - resolution.publicResolutionError?.let { throw it } + if ( + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || + resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST + ) { + schedulePendingPrivateMessageDrainRetries( + reason = "payment recovery", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + ) + } + if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { + return PublicPaykitPaymentResult.WaitingForUpdatedPaymentList + } - if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { - PublicPaykitPaymentResult.NoEndpoint - } else { - PublicPaykitPaymentResult.NotOpened - } - } + return if (acceptedEndpoints.isEmpty()) { + PublicPaykitPaymentResult.NoEndpoint + } else { + PublicPaykitPaymentResult.NotOpened } + } + + private suspend fun currentLinkState( + publicKey: String, + receiverPath: String, + preparedState: LinkedPeerState? = null, + ): LinkedPeerState? = preparedState ?: paykitSdkService.linkedPeers().firstOrNull { + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.counterpartyReceiverPath == receiverPath + }?.state + + private fun canUsePublicPayment( + linkState: LinkedPeerState?, + resolutionStatus: PrivatePaymentResolutionStatus? = null, + resolutionState: PrivatePaymentResolutionState? = null, + ): Boolean { + if ( + resolutionStatus == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST || + resolutionState != null && resolutionState != PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT + ) { + return false + } + + return when (linkState) { + null, LinkedPeerState.NOT_LINKED, LinkedPeerState.LINKING -> true + LinkedPeerState.LINKED, + LinkedPeerState.RECOVERY_REQUIRED, + LinkedPeerState.BLOCKED, + LinkedPeerState.UNKNOWN, + -> false + } + } private suspend fun publishLocalEndpoints( publicKeys: Collection, @@ -1347,10 +1458,11 @@ class PrivatePaykitRepo @Inject constructor( lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { - pubkyService.currentPublicKey() ?: return@runSuspendCatching false - paykitSdkService.hasPrivatePaymentAccess() - }.getOrDefault(false) + private suspend fun hasLiveSessionForCurrentProfile(): Boolean { + pubkyService.currentPublicKey() ?: return false + val status = paykitSdkService.identityStatus() ?: return false + return status.liveSessionAvailable + } private suspend fun isContactSharingCleanupPending(): Boolean = cacheStore.data.first().cleanupPending diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 170e1a9481..91e277bb9c 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -47,11 +47,21 @@ sealed class PublicPaykitError(message: String) : AppError(message) { } sealed interface PublicPaykitPaymentResult { - data class Opened(val paymentRequest: String) : PublicPaykitPaymentResult + data class Opened( + val paymentRequest: String, + val privatePaymentContext: PrivatePaykitPaymentContext? = null, + ) : PublicPaykitPaymentResult + data object NoEndpoint : PublicPaykitPaymentResult data object NotOpened : PublicPaykitPaymentResult + data object WaitingForUpdatedPaymentList : PublicPaykitPaymentResult } +data class PrivatePaykitPaymentContext( + val receiverPath: String, + val paymentListVersion: ULong, +) + @OptIn(ExperimentalTime::class) @Suppress("LongParameterList") @Singleton diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9e462e298a..5d030c8bba 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -8,6 +8,8 @@ import com.synonym.paykit.ContactUpdate import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.OutboundPrivateCounterpartySendReport import com.synonym.paykit.PaykitAndroid import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile @@ -16,7 +18,9 @@ import com.synonym.paykit.PaykitReceiverCapabilities import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults +import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PaymentPayload +import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution import com.synonym.paykit.PrivatePaymentEndpointCandidate @@ -25,9 +29,11 @@ import com.synonym.paykit.PrivatePaymentEndpointSelectionRequest import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PrivateReceivingDetail import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind +import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyLocalSecretKey @@ -77,8 +83,19 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton -data class PaykitContactPaymentResolution( - val privateState: PrivatePaymentResolutionState, +data class PaykitPreparedPrivateContactPayment( + val resolution: PaykitPrivateContactPaymentResolution, + val linkState: LinkedPeerState?, +) + +data class PaykitPrivateContactPaymentResolution( + val status: PrivatePaymentResolutionStatus, + val state: PrivatePaymentResolutionState, + val privatePaymentListVersion: ULong?, + val payableEndpoints: List, +) + +data class PaykitPublicContactPaymentResolution( val payableEndpoints: List, val privatePaymentListVersion: ULong? = null, val publicResolutionError: Throwable? = null, @@ -90,8 +107,6 @@ enum class PaykitPaymentEndpointSource { } data class PaykitResolvedPaymentEndpoint( - val counterparty: String, - val source: PaykitPaymentEndpointSource, val identifier: String, val payload: String, ) @@ -112,7 +127,7 @@ internal object PaykitReceiverPaths { } @Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, @@ -523,24 +538,44 @@ class PaykitSdkService @Inject constructor( } } - suspend fun receivePrivateMessagesFromLinkedPeers() { + suspend fun receivePrivateMessagesFromLinkedPeers(): List { isSetup.await() - operationMutex.withLock { + return operationMutex.withLock { withStateRevisionTracking { handle -> handle.receivePrivateMessagesFromLinkedPeers() } } } - suspend fun processPendingPrivateMessages() { + suspend fun processPendingPrivateMessages(): List { isSetup.await() - operationMutex.withLock { + return operationMutex.withLock { withStateRevisionTracking { handle -> handle.processPendingPrivateMessages() } } } + suspend fun actionableReceivedPaymentRequests(): List { + isSetup.await() + return operationMutex.withLock { + handle().actionableReceivedPaymentRequests() + } + } + + suspend fun acceptPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.acceptPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId) + } + } + } + suspend fun linkedPeers(): List { isSetup.await() return operationMutex.withLock { @@ -555,19 +590,19 @@ class PaykitSdkService @Inject constructor( } } - suspend fun prepareAndResolveContactPayment( + suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, receiverPath: String, - includePublicEndpoints: Boolean, - afterPrivatePaymentListVersion: ULong? = null, - ): PaykitContactPaymentResolution { + afterPrivatePaymentListVersion: ULong?, + amount: PaymentAmountContext? = null, + ): PaykitPreparedPrivateContactPayment { isSetup.await() val (privateResolution, publicResolution) = operationMutex.withLock { withStateRevisionTracking { handle -> - val privateResolution = handle.prepareAndResolvePrivateContactPayment( + handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, counterpartyReceiverPath = receiverPath, - amount = null, + amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, ).resolution @@ -581,33 +616,40 @@ class PaykitSdkService @Inject constructor( privateResolution to publicResolution } } - return privateResolution.toPaykitContactPaymentResolution( - publicResolution = publicResolution?.getOrNull(), - publicResolutionError = publicResolution?.exceptionOrNull(), + return PaykitPreparedPrivateContactPayment( + resolution = prepared.resolution.toPaykitPrivateContactPaymentResolution(), + linkState = prepared.linkReport?.state, ) } suspend fun resolvePublicContactPayment( counterparty: String, receiverPath: String, - ): PaykitContactPaymentResolution { + ): PaykitPublicContactPaymentResolution { isSetup.await() val resolution = operationMutex.withLock { handle().resolvePublicContactPayment(counterparty, receiverPath, amount = null) } - return resolution.toPaykitContactPaymentResolution() + return resolution.toPaykitPublicContactPaymentResolution() } - private fun PrivateContactPaymentResolution.toPaykitContactPaymentResolution( - publicResolution: PublicContactPaymentResolution?, - publicResolutionError: Throwable?, - ): PaykitContactPaymentResolution { - return PaykitContactPaymentResolution( - privateState = state, + private fun PrivateContactPaymentResolution.toPaykitPrivateContactPaymentResolution() = + PaykitPrivateContactPaymentResolution( + status = status, + state = state, + privatePaymentListVersion = privatePaymentListVersion, + payableEndpoints = payableEndpoints.map { + PaykitResolvedPaymentEndpoint( + identifier = it.identifier, + payload = it.target.payload.exportText(), + ) + }, + ) + + private fun PublicContactPaymentResolution.toPaykitPublicContactPaymentResolution() = + PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( - counterparty = it.counterparty, - source = PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST, identifier = it.identifier, payload = it.target.payload.exportText(), ) @@ -630,7 +672,6 @@ class PaykitSdkService @Inject constructor( identifier = it.identifier, payload = it.target.payload.exportText(), ) - } suspend fun exportBackupState(): String { isSetup.await() @@ -728,7 +769,30 @@ class PaykitSdkService @Inject constructor( keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } resetRuntime() - handle().initialize() + val handle = handle() + handle.initialize() + publishReceiverMarkerIfLiveSessionAvailable(handle) + } + + private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { + runSuspendCatching { + val capabilities = receiverCapabilities(handle) + if (capabilities.privatePayments) { + handle.publishPaykitReceiverMarker(capabilities) + } + }.onFailure { + Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + } + } + + private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { + val status = handle.identityStatus() + return PaykitReceiverCapabilities( + privatePayments = status?.liveSessionAvailable == true, + paymentRequests = status?.liveSessionAvailable == true, + receipts = false, + outgoingPayments = true, + ) } private fun notifyBackupStateChanged() { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f79e60ff67..b42754c4d2 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -277,9 +277,11 @@ fun ContentView( blocktankViewModel.refreshOrders() appViewModel.refreshPublicPaykitEndpoints() appViewModel.refreshPrivatePaykitEndpoints() + appViewModel.startPaykitPaymentRequestPolling() } Lifecycle.Event.ON_STOP -> { + appViewModel.stopPaykitPaymentRequestPolling() val keptAliveByService = notificationsGranted && keepActiveInBackground && appViewModel.isForegroundServiceRunning() @@ -295,6 +297,7 @@ fun ContentView( lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) + appViewModel.stopPaykitPaymentRequestPolling() } } @@ -1209,8 +1212,8 @@ private fun NavGraphBuilder.contacts( ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, - onPayContact = { paymentRequest, publicKey -> - appViewModel.openContactPayment(paymentRequest, publicKey) + onPayContact = { paymentRequest, publicKey, privatePaymentContext -> + appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, showDeleteAction = route.showDeleteAction, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 93fd35c11c..e74ce7b0b3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -126,6 +126,8 @@ class AddContactViewModel @Inject constructor( showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { 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 7fb3da656b..7cb936a49e 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 @@ -30,6 +30,7 @@ import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM @@ -52,7 +53,7 @@ import to.bitkit.ui.theme.Colors fun ContactDetailScreen( viewModel: ContactDetailViewModel, onBackClick: () -> Unit, - onPayContact: (String, String) -> Unit, + onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Unit, onActivityClick: (String) -> Unit, showDeleteAction: Boolean = false, onContactDeleted: () -> Unit = {}, @@ -64,7 +65,8 @@ fun ContactDetailScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) + is ContactDetailEffect.OpenPayment -> + onPayContact(it.paymentRequest, it.publicKey, it.privatePaymentContext) ContactDetailEffect.ContactDeleted -> onContactDeleted() } } 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 495d8bf73f..e3ee5d0503 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 @@ -25,6 +25,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -105,11 +106,19 @@ class ContactDetailViewModel @Inject constructor( .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> - _effects.emit(ContactDetailEffect.OpenPayment(result.paymentRequest, publicKey)) + _effects.emit( + ContactDetailEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + ) + ) PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { @@ -249,6 +258,11 @@ data class ContactDetailUiState( ) sealed interface ContactDetailEffect { - data class OpenPayment(val paymentRequest: String, val publicKey: String) : ContactDetailEffect + data class OpenPayment( + val paymentRequest: String, + val publicKey: String, + val privatePaymentContext: PrivatePaykitPaymentContext?, + ) : ContactDetailEffect + data object ContactDeleted : ContactDetailEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 8dd6a71402..5d0929bdce 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -211,6 +211,7 @@ private fun Content( SendContactTopBar( titleText = when { + uiState.isPaymentRequest -> stringResource(R.string.wallet__payment_request) isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index 1acbbc9fe2..b159762811 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BodySSB @@ -41,14 +42,15 @@ import to.bitkit.ui.theme.Colors fun SendContactSelectScreen( viewModel: SendContactSelectViewModel, onBack: () -> Unit, - onOpenPayment: (String, String) -> Unit, + onOpenPayment: (String, String, PrivatePaykitPaymentContext?) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is SendContactSelectEffect.OpenPayment -> onOpenPayment(it.paymentRequest, it.publicKey) + is SendContactSelectEffect.OpenPayment -> + onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index 54a9c355c4..201ef5e9d7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -69,11 +70,19 @@ class SendContactSelectViewModel @Inject constructor( .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> - _effects.emit(SendContactSelectEffect.OpenPayment(result.paymentRequest, publicKey)) + _effects.emit( + SendContactSelectEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + ) + ) PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { @@ -105,5 +114,9 @@ data class SendContactSelectUiState( ) sealed interface SendContactSelectEffect { - data class OpenPayment(val paymentRequest: String, val publicKey: String) : SendContactSelectEffect + data class OpenPayment( + val paymentRequest: String, + val publicKey: String, + val privatePaymentContext: PrivatePaykitPaymentContext?, + ) : SendContactSelectEffect } 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 1f54c44421..3b7257b071 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -155,8 +155,8 @@ fun SendSheet( appViewModel.clearActiveContactPaymentContext() navController.popBackStack() }, - onOpenPayment = { paymentRequest, publicKey -> - appViewModel.openContactPayment(paymentRequest, publicKey) + onOpenPayment = { paymentRequest, publicKey, privatePaymentContext -> + appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 563721c97c..8340c78ee2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -51,6 +51,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -132,13 +134,18 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo @@ -210,6 +217,7 @@ class AppViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, + private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -278,6 +286,9 @@ class AppViewModel @Inject constructor( private val contactPaymentContextLock = Any() private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() + private val presentedPaymentRequestIds = mutableSetOf() + private var isPresentingPaymentRequest = false + private var paykitPaymentRequestPollingJob: Job? = null private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -399,6 +410,8 @@ class AppViewModel @Inject constructor( observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() + observePaykitPaymentRequestConnectivity() + observeIncomingPaykitPaymentRequests() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -534,6 +547,7 @@ class AppViewModel @Inject constructor( .collect { state -> if (!state.isPaykitEnabled || state.publicKey == null) { lastPrivatePaykitContactKeys = emptySet() + paykitPaymentRequestRepo.clear() return@collect } @@ -560,6 +574,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + refreshIncomingPaykitPaymentRequests() lastPrivatePaykitContactKeys = state.contactKeys } } @@ -580,6 +595,66 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + refreshIncomingPaykitPaymentRequests() + } + + private fun observePaykitPaymentRequestConnectivity() { + viewModelScope.launch { + isOnline + .drop(1) + .filter { it == ConnectivityState.CONNECTED } + .collect { refreshIncomingPaykitPaymentRequests() } + } + } + + private suspend fun refreshIncomingPaykitPaymentRequests() { + if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return + paykitPaymentRequestRepo.refresh() + } + + fun startPaykitPaymentRequestPolling() { + if (paykitPaymentRequestPollingJob?.isActive == true) return + + paykitPaymentRequestPollingJob = viewModelScope.launch { + while (true) { + delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) + refreshIncomingPaykitPaymentRequests() + } + } + } + + fun stopPaykitPaymentRequestPolling() { + paykitPaymentRequestPollingJob?.cancel() + paykitPaymentRequestPollingJob = null + } + + private fun observeIncomingPaykitPaymentRequests() { + viewModelScope.launch { + combine(paykitPaymentRequestRepo.pendingRequests, currentSheet) { requests, sheet -> requests to sheet } + .collect { (requests, sheet) -> + presentedPaymentRequestIds.retainAll(requests.mapTo(mutableSetOf()) { it.id }) + if (sheet != null || isPresentingPaymentRequest) return@collect + val request = requests.firstOrNull { it.id !in presentedPaymentRequestIds } ?: return@collect + presentIncomingPaykitPaymentRequest(request) + } + } + } + + private suspend fun presentIncomingPaykitPaymentRequest(request: PaykitPaymentRequest) { + isPresentingPaymentRequest = true + privatePaykitRepo.beginPaymentRequest(request) + .onSuccess { result -> + if (result is PublicPaykitPaymentResult.Opened && currentSheet.value == null) { + presentedPaymentRequestIds += request.id + openContactPayment( + paymentRequest = result.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = result.privatePaymentContext, + incomingPaymentRequest = request, + ) + } + } + isPresentingPaymentRequest = false } private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { @@ -1678,9 +1753,18 @@ class AppViewModel @Inject constructor( ) } - fun openContactPayment(paymentRequest: String, publicKey: String) { + fun openContactPayment( + paymentRequest: String, + publicKey: String, + privatePaymentContext: PrivatePaykitPaymentContext? = null, + incomingPaymentRequest: PaykitPaymentRequest? = null, + ) { synchronized(contactPaymentContextLock) { - activeContactPaymentContext = ContactPaymentContext(publicKey, paymentRequest) + activeContactPaymentContext = ContactPaymentContext( + publicKey = publicKey, + privatePaymentContext = privatePaymentContext, + incomingPaymentRequest = incomingPaymentRequest, + ) } onScanResult(paymentRequest) } @@ -1701,8 +1785,9 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, ) = withContext(bgDispatcher) { val contactPaymentProfile = activeContactPaymentProfile() + val isPaymentRequest = activeIncomingPaymentRequest() != null // always reset state on new scan - resetSendState(contactPaymentProfile = contactPaymentProfile) + resetSendState(contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest) resetQuickPay() val fromMainScanner = isMainScanner @@ -1867,8 +1952,8 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.publicKey } - private fun activeContactPaymentRequest() = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.paymentRequest + private fun activeIncomingPaymentRequest() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest } private fun activeContactPaymentProfile(): PubkyProfile? { @@ -1911,7 +1996,10 @@ class AppViewModel @Inject constructor( val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats val lnInvoice = extractViableLightningInvoice(invoice.params) - val amount = lnInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: invoice.amountSatoshis + val incomingPaymentRequest = activeIncomingPaymentRequest() + val amount = incomingPaymentRequest?.amountSats + ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } + ?: invoice.amountSatoshis _sendUiState.update { it.copy( address = invoice.address, @@ -1925,6 +2013,51 @@ class AppViewModel @Inject constructor( } updateCanSwitchWallet() + if (incomingPaymentRequest != null) { + if (lnInvoice != null) { + lightningRepo.waitForUsableChannels() + if (!lightningRepo.canSend(amount) && amount <= maxSendOnchain) { + _sendUiState.update { it.copy(payMethod = SendMethod.ONCHAIN) } + } + } + if (!validateAmount(amount)) { + val isLightning = _sendUiState.value.payMethod == SendMethod.LIGHTNING + val maxSendable = if (isLightning) { + walletRepo.balanceState.value.maxSendLightningSats + } else { + walletRepo.balanceState.value.maxSendOnchainSats + } + val shortfall = amount.safe() - maxSendable.safe() + toast( + type = Toast.ToastType.ERROR, + title = context.getString( + if (isLightning) { + R.string.other__pay_insufficient_spending + } else { + R.string.other__pay_insufficient_savings + } + ), + description = context.getString( + if (isLightning) { + R.string.other__pay_insufficient_spending_amount_description + } else { + R.string.other__pay_insufficient_savings_amount_description + } + ).replace( + "{amount}", + formatMoneyValue(shortfall), + ), + ) + clearActiveContactPaymentContext() + return + } + + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) + refreshOnchainSendIfNeeded() + estimateLightningRoutingFeesIfNeeded() + return + } + val lnAmountSats = lnInvoice?.amountSatoshis ?: 0u if (lnAmountSats > 0u) { Logger.info("Found amount in unified invoice, checking QuickPay conditions", context = TAG) @@ -2000,18 +2133,19 @@ class AppViewModel @Inject constructor( return } + val amount = activeIncomingPaymentRequest()?.amountSats ?: invoice.amountSatoshis val quickPayHandled = handleQuickPayIfApplicable( - amountSats = invoice.amountSatoshis, + amountSats = amount, invoice = invoice, fromMainScanner = fromMainScanner, ) if (quickPayHandled) return lightningRepo.waitForUsableChannels() - if (!lightningRepo.canSend(invoice.amountSatoshis)) { + if (!lightningRepo.canSend(amount)) { hideSheet() val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats - val shortfall = invoice.amountSatoshis.safe() - maxSendLightning.safe() + val shortfall = amount.safe() - maxSendLightning.safe() toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__pay_insufficient_spending), @@ -2025,7 +2159,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( - amount = invoice.amountSatoshis, + amount = amount, addressInput = scanResult, isAddressInputValid = true, decodedInvoice = invoice, @@ -2033,7 +2167,7 @@ class AppViewModel @Inject constructor( ) } - if (invoice.amountSatoshis > 0uL) { + if (amount > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) @@ -2049,9 +2183,20 @@ class AppViewModel @Inject constructor( val isFixed = data.isFixedAmount() val displaySats = data.minSendableSat() + val incomingAmount = activeIncomingPaymentRequest()?.amountSats + if (incomingAmount != null && incomingAmount !in displaySats..data.maxSendableSat()) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__lnurl_pay_error), + description = context.getString(R.string.other__scan__error__generic), + ) + clearActiveContactPaymentContext() + return + } + val paymentAmount = incomingAmount ?: displaySats lightningRepo.waitForUsableChannels() - if (!lightningRepo.canSend(displaySats.coerceAtLeast(1u))) { + if (!lightningRepo.canSend(paymentAmount.coerceAtLeast(1u))) { hideSheet() toast( type = Toast.ToastType.WARNING, @@ -2062,7 +2207,7 @@ class AppViewModel @Inject constructor( return } - val initialAmount = if (isFixed) displaySats else 0u + val initialAmount = incomingAmount ?: if (isFixed) displaySats else 0u _sendUiState.update { it.copy( @@ -2072,11 +2217,11 @@ class AppViewModel @Inject constructor( ) } - if (isFixed) { + if (isFixed || incomingAmount != null) { Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) val quickPayHandled = handleQuickPayIfApplicable( - amountSats = displaySats, + amountSats = initialAmount, lnurlPay = data, fromMainScanner = fromMainScanner, ) @@ -2355,6 +2500,17 @@ class AppViewModel @Inject constructor( private suspend fun proceedWithPayment() { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable + acceptIncomingPaymentRequestIfNeeded().onFailure { + toast(it) + return + } + + consumePrivatePaymentListIfNeeded().onFailure { + toast(it) + hideSheet() + return + } + val amount = _sendUiState.value.amount val lnurl = _sendUiState.value.lnurl @@ -2442,37 +2598,32 @@ class AppViewModel @Inject constructor( } } - discardContactLightningEndpoint(contactPublicKey, paymentHash, contactPaymentRequest) - .fold( - onSuccess = { sendLightning(bolt11, paymentAmount) }, - onFailure = { Result.failure(it) }, + sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> + Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = actualPaymentHash, + sats = displayAmountSats.toLong(), // TODO Add fee when available + ), ) - .onSuccess { actualPaymentHash -> - Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = actualPaymentHash, - sats = displayAmountSats.toLong(), // TODO Add fee when available - ), - ) - }.onFailure { - if (it is PaymentPendingException) { - Logger.info("Lightning payment pending", context = TAG) - pendingPaymentRepo.track(it.paymentHash) - preserveContactPaymentContext(it.paymentHash) - setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) - return@onFailure - } - // Delete pre-activity metadata on failure - if (createdMetadataPaymentId != null) { - preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) - } - Logger.error("Error sending lightning payment", it, context = TAG) - toast(it) - hideSheet() + }.onFailure { + if (it is PaymentPendingException) { + Logger.info("Lightning payment pending", context = TAG) + pendingPaymentRepo.track(it.paymentHash) + preserveContactPaymentContext(it.paymentHash) + setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) + return@onFailure + } + // Delete pre-activity metadata on failure + if (createdMetadataPaymentId != null) { + preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) } + Logger.error("Error sending lightning payment", it, context = TAG) + toast(it) + hideSheet() + } } } } @@ -2777,7 +2928,10 @@ class AppViewModel @Inject constructor( ).getOrDefault(0u).toLong() } - suspend fun resetSendState(contactPaymentProfile: PubkyProfile? = null) { + suspend fun resetSendState( + contactPaymentProfile: PubkyProfile? = null, + isPaymentRequest: Boolean = false, + ) { addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed val rates = let { @@ -2791,6 +2945,7 @@ class AppViewModel @Inject constructor( speed = speed, feeRates = rates, contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, ) } } @@ -3153,37 +3308,18 @@ class AppViewModel @Inject constructor( } } - private suspend fun discardContactLightningEndpoint( - contactPublicKey: String?, - paymentHash: String, - paymentRequest: String?, - ): Result { - if (contactPublicKey == null) return Result.success(Unit) - return privatePaykitRepo.discardRemoteLightningEndpoints( - publicKey = contactPublicKey, - paymentHashes = setOf(paymentHash), - paymentRequests = setOfNotNull(paymentRequest), - ).onFailure { - Logger.warn( - "Failed to discard private Paykit invoice for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", - it, - context = TAG, - ) - } + private suspend fun consumePrivatePaymentListIfNeeded(): Result { + val context = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + ?: return Result.success(Unit) + val privatePaymentContext = context.privatePaymentContext ?: return Result.success(Unit) + return privatePaykitRepo.consumePrivatePaymentList(context.publicKey, privatePaymentContext) } - private suspend fun discardContactOnchainEndpoint( - contactPublicKey: String?, - address: String, - ): Result { - if (contactPublicKey == null) return Result.success(Unit) - return privatePaykitRepo.discardRemoteOnchainEndpoints(contactPublicKey, setOf(address)).onFailure { - Logger.warn( - "Failed to discard private Paykit address for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", - it, - context = TAG, - ) - } + private suspend fun acceptIncomingPaymentRequestIfNeeded(): Result { + val request = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest + } ?: return Result.success(Unit) + return paykitPaymentRequestRepo.accept(request) } fun handleDeeplinkIntent(intent: Intent) { @@ -3361,6 +3497,7 @@ class AppViewModel @Inject constructor( fun onHomeResumed() { checkTimedSheets() hwWalletRepo.onAppForegrounded() + viewModelScope.launch { refreshIncomingPaykitPaymentRequests() } } fun onLeftHome() = timedSheetManager.onHomeScreenExited() @@ -3416,6 +3553,7 @@ class AppViewModel @Inject constructor( 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 PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL = 30.seconds private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" @@ -3456,6 +3594,7 @@ data class SendUiState( val estimatedRoutingFee: ULong = 0uL, val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, + val isPaymentRequest: Boolean = false, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -3475,7 +3614,8 @@ enum class SendMethod { ONCHAIN, LIGHTNING } data class ContactPaymentContext( val publicKey: String, - val paymentRequest: String? = null, + val privatePaymentContext: PrivatePaykitPaymentContext? = null, + val incomingPaymentRequest: PaykitPaymentRequest? = null, ) private data class PaykitContactSyncState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71616e5db7..478ca60fc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1148,6 +1148,7 @@ MINIMUM Note Received Bitcoin + Payment Request Peer disconnected. Receive Receive Lightning funds diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt new file mode 100644 index 0000000000..3c106dda84 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -0,0 +1,192 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" + private const val COUNTERPARTY = "pubkypayee" + private val START_TIME = Instant.parse("2027-01-15T08:00:00Z") + private val PAYMENT_REFERENCE = mock { + on { exportText() } doReturn "invoice-123" + } + private val METADATA = mock { + on { exportText() } doReturn """{"order":"123"}""" + } + } + + private val paykitSdkService = mock() + private var schedulerOriginMillis = 0L + private val clock = object : Clock { + override fun now(): Instant = START_TIME.plus( + (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, + ) + } + private lateinit var sut: PaykitPaymentRequestRepo + + @Before + fun setUp() = test { + schedulerOriginMillis = testDispatcher.scheduler.currentTime + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(emptyList()) + sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, clock) + } + + @After + fun tearDown() = test { + sut.clear() + } + + @Test + fun `refresh maps actionable bitcoin request`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + + sut.refresh().getOrThrow() + + val request = sut.pendingRequests.value.single() + assertEquals(100_000uL, request.amountSats) + assertEquals("invoice-123", request.paymentReference) + assertEquals("""{"order":"123"}""", request.metadata) + assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) + } + + @Test + fun `refresh drops expired unsupported and non payer requests`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + listOf( + paymentRequestRecord(expiresAt = clock.now().toString()), + paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), + paymentRequestRecord(id = "payee", role = PaymentRequestLocalRole.PAYEE), + ), + ) + + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `pending request is removed exactly when it expires`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), + ) + sut.refresh().getOrThrow() + + advanceTimeBy(9_999) + runCurrent() + assertEquals(1, sut.pendingRequests.value.size) + + advanceTimeBy(1) + runCurrent() + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `accept removes current request and delivers queued response`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ), + ).thenReturn(record) + sut.refresh().getOrThrow() + clearInvocations(paykitSdkService) + + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + } + + @Test + fun `expired request cannot be accepted`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + advanceTimeBy(1_000) + + assertFailsWith { + sut.accept(request).getOrThrow() + } + verifyBlocking(paykitSdkService, never()) { + acceptPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + } + + @Suppress("LongParameterList") + private fun paymentRequestRecord( + id: String = PAYMENT_REQUEST_ID, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYER, + amount: String = "0.001", + expiresAt: String? = null, + endpoints: List = listOf(MethodId.Bolt11.rawValue), + ) = PaymentRequestRecord( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + paymentRequestId = id, + localRole = role, + state = PaymentRequestLifecycleState.PROPOSED, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "proposal-event", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = amount, asset = "btc"), + paymentReference = PAYMENT_REFERENCE, + proposalExpiresAt = expiresAt, + recurrence = null, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = METADATA, + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = emptyList(), + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = clock.now().toString(), + invalidReason = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index f1932eb3b3..82ff5c8417 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -8,10 +8,12 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -46,8 +48,8 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState import to.bitkit.services.CoreService -import to.bitkit.services.PaykitContactPaymentResolution -import to.bitkit.services.PaykitPaymentEndpointSource +import to.bitkit.services.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService @@ -62,6 +64,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -700,579 +703,242 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment uses public SDK endpoint when private capability is unavailable`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) + fun `beginSavedContactPayment uses public resolution while Noise link is not established`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) }.thenReturn( resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + status = PrivatePaymentResolutionStatus.NO_ENDPOINT, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKING, + version = null, ), ) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) - } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } } @Test - fun `beginSavedContactPayment opens SDK resolved private endpoint`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment uses public resolution without live Noise session`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + liveSessionAvailable = false, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } + verifyBlocking(paykitSdkService, never()) { + prepareAndResolvePrivateContactPayment(any(), any(), any(), any()) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `private payment consumes the complete payment list version`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment opens private endpoint with its list version`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = null, - ) - }.thenReturn( - resolution( - resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), - resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), - privatePaymentListVersion = 7uL, - ), - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) whenever(coreService.decode(PRIVATE_BOLT11)) .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) }.thenReturn(false) - - assertTrue(sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() is PublicPaykitPaymentResult.Opened) - sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).getOrThrow() - - val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) - assertTrue(contactCache.remoteEndpoints.isEmpty()) - assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) - whenever(paykitSdkService.exportBackupState()).thenReturn("sdk-backup") - val backup = sut.backupSnapshot().getOrThrow() - sut.restoreBackup(backup).getOrThrow() - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = 7uL, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - ), - ) + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() assertEquals( - PublicPaykitPaymentResult.Opened("bcrt1qpublic"), - sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), - ) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = 7uL, - ) - } - } - - @Test - fun `private lnurl payment consumes the complete payment list version`() = test { - val lnurl = "lnurl1private" - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = null, - ) - }.thenReturn( - resolution( - resolvedEndpoint(MethodId.Lnurl, lnurl), - privatePaymentListVersion = 7uL, + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), ), + result, ) - - assertEquals( - PublicPaykitPaymentResult.Opened(lnurl), - sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), - ) - sut.discardRemoteLightningEndpoints( - publicKey = CONTACT_KEY, - paymentHashes = emptySet(), - paymentRequests = setOf(lnurl), - ).getOrThrow() - - val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) - assertTrue(contactCache.remoteEndpoints.isEmpty()) - assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) - } - - @Test - fun `private payment list remains available when consumption persistence fails`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = null, - ) - }.thenReturn( - resolution( - resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), - privatePaymentListVersion = 7uL, - ), - ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - assertTrue(sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() is PublicPaykitPaymentResult.Opened) - - whenever { cacheStore.update(any()) }.thenAnswer { throw AppError("write failed") } - assertTrue(sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).isFailure) - - whenever { cacheStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(PrivatePaykitCacheData) -> PrivatePaykitCacheData>(0) - cacheData.value = transform(cacheData.value) - } - sut.discardRemoteLightningEndpoints(CONTACT_KEY, setOf("090909")).getOrThrow() - - val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) - assertTrue(contactCache.remoteEndpoints.isEmpty()) - assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `private payment filtering keeps the unattempted list available`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment never falls back while linked recovery is pending`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = null, - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) }.thenReturn( resolution( - resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), - resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), - privatePaymentListVersion = 7uL, + status = PrivatePaymentResolutionStatus.NO_ENDPOINT, + state = PrivatePaymentResolutionState.RECOVERY_PENDING, + linkState = LinkedPeerState.RECOVERY_REQUIRED, + version = null, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) }.thenReturn(false) - PublicPaykitRepo.lightningRouteHintsValidator = { false } - assertEquals( - PublicPaykitPaymentResult.Opened(PRIVATE_ADDRESS), - sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow(), - ) + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) - assertEquals( - listOf(PublicPaykitRepo.serializePayload(PRIVATE_ADDRESS)), - contactCache.remoteEndpoints.map { it.endpointData }, - ) - assertTrue(contactCache.consumedPaymentListVersionsByReceiverPath.isEmpty()) - assertEquals(7uL, contactCache.remotePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment waits for newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) }.thenReturn( resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKED, + version = null, ), ) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - val captor = argumentCaptor>() - verifyBlocking(paykitSdkService) { syncPrivatePaymentListsWithReservations(captor.capture(), eq(false)) } - assertEquals(CONTACT_KEY, captor.firstValue.single().counterparty) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) - } - } - - @Test - fun `beginSavedContactPayment does not fall back to public when unified resolution is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } - - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - } - - @Test - fun `beginSavedContactPayment does not fall back to public when private refresh is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } - .thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } - - verifyBlocking(paykitSdkService, never()) { - prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) - } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - } - - @Test - fun `beginSavedContactPayment does not fall back to public when endpoint build is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever { walletRepo.refreshReusableReceiveAddressIfReserved() } - .thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } - - verifyBlocking(paykitSdkService, never()) { - prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) - } + assertEquals(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, result) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment does not fall back to public when publish gate is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever(pubkyService.currentPublicKey()).thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } - - verifyBlocking(paykitSdkService, never()) { - prepareAndResolveContactPayment(any(), any(), any(), anyOrNull()) - } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - } - - @Test - fun `beginSavedContactPayment falls back to public when unified resolution fails`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment only falls back after failure when Noise link is absent`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) }.thenThrow(IllegalStateException("private unavailable")) - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( - Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), - ) - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - - assertEquals(PublicPaykitPaymentResult.Opened("public-fallback"), result) - verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } - } - - @Test - fun `beginSavedContactPayment falls back when public resolution fails without a private endpoint`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution(publicResolutionError = AppError("public lookup failed")), - ) - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( - Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), - ) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("public-fallback"), result) + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } } @Test - fun `beginSavedContactPayment uses a private endpoint when public resolution fails`() = test { - val lnurl = "lnurl1private" - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment propagates failure when Noise link exists`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint(MethodId.Lnurl, lnurl), - publicResolutionError = AppError("public lookup failed"), - ), - ) - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenThrow(IllegalStateException("private unavailable")) + whenever(paykitSdkService.linkedPeers()) + .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) - assertEquals(PublicPaykitPaymentResult.Opened(lnurl), result) + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment uses public endpoint from unified resolution when private has no endpoints`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - ), - ) + fun `consumePrivatePaymentList persists version clears list and rejects reuse`() = test { + val context = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + assertEquals( + 7uL, + cacheData.value.contacts.getValue(CONTACT_KEY) + .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + ) + assertFailsWith { + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() + } } @Test - fun `beginSavedContactPayment uses public endpoint while private recovery is pending`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment passes consumed list version to private resolver`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) + sut.consumePrivatePaymentList( + CONTACT_KEY, + PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ).getOrThrow() whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) }.thenReturn( resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - privateState = PrivatePaymentResolutionState.RECOVERY_PENDING, + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKED, + version = null, ), ) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + verifyBlocking(paykitSdkService) { + prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + } } @Test - fun `beginSavedContactPayment returns no endpoint when recovery pending has no public endpoint`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment does not fall back when private resolution is cancelled`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution(privateState = PrivatePaymentResolutionState.RECOVERY_PENDING), - ) - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenThrow(CancellationException("cancelled")) - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment falls back to public when private endpoints are not locally payable`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) + fun `beginPaymentRequest resolves only accepted private endpoints with the requested amount`() = test { + val request = paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), ) }.thenReturn( resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + version = 7uL, ), ) - whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { - val endpoints = it.getArgument>(0) - val hasLightningEndpoint = endpoints.any { endpoint -> endpoint.methodId == MethodId.Bolt11 } - endpoints.takeUnless { hasLightningEndpoint }.orEmpty() - } - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - } + val result = sut.beginPaymentRequest(request).getOrThrow() - @Test - fun `beginSavedContactPayment does not fall back to public when private payable check is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = PRIVATE_ADDRESS, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), ), + result, ) - whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) } - .thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + val amountCaptor = argumentCaptor() + verifyBlocking(paykitSdkService) { + prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + amountCaptor.capture(), + ) } - + assertEquals("0.000025", amountCaptor.firstValue.value) + assertEquals("btc", amountCaptor.firstValue.asset) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment does not fall back to public when private invoice decode is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + fun `beginPaymentRequest never falls back to public resolution without live Noise session`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + liveSessionAvailable = false, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenThrow(CancellationException("cancelled")) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + assertFailsWith { + sut.beginPaymentRequest(paymentRequest()).getOrThrow() } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1280,11 +946,20 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `backupSnapshot and restoreBackup use SDK backup state`() = test { val backup = "sdk-backup" whenever(paykitSdkService.exportBackupState()).thenReturn(backup) + sut.consumePrivatePaymentList( + CONTACT_KEY, + PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ).getOrThrow() val snapshot = sut.backupSnapshot().getOrThrow() sut.restoreBackup(snapshot).getOrThrow() - assertTrue(snapshot?.startsWith("bitkit-paykit-v1:") == true) + assertTrue(snapshot?.contains(backup) == true) + assertEquals( + 7uL, + cacheData.value.contacts.getValue(CONTACT_KEY) + .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + ) verifyBlocking(paykitSdkService) { restoreBackupState(backup) } } @@ -1304,29 +979,52 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolution( vararg endpoints: PaykitResolvedPaymentEndpoint, - privateState: PrivatePaymentResolutionState = PrivatePaymentResolutionState.AVAILABLE, - privatePaymentListVersion: ULong? = null, - publicResolutionError: Throwable? = null, - ) = PaykitContactPaymentResolution( - privateState = privateState, - payableEndpoints = endpoints.toList(), - privatePaymentListVersion = privatePaymentListVersion, - publicResolutionError = publicResolutionError, + status: PrivatePaymentResolutionStatus = if (endpoints.isEmpty()) { + PrivatePaymentResolutionStatus.NO_ENDPOINT + } else { + PrivatePaymentResolutionStatus.PAYABLE + }, + state: PrivatePaymentResolutionState = if (endpoints.isEmpty()) { + PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT + } else { + PrivatePaymentResolutionState.AVAILABLE + }, + version: ULong? = 1uL, + linkState: LinkedPeerState? = LinkedPeerState.LINKED, + ) = PaykitPreparedPrivateContactPayment( + resolution = PaykitPrivateContactPaymentResolution( + status = status, + state = state, + privatePaymentListVersion = version, + payableEndpoints = endpoints.toList(), + ), + linkState = linkState, ) private fun resolvedEndpoint( methodId: MethodId, value: String, - source: PaykitPaymentEndpointSource = PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( - counterparty = CONTACT_KEY, - source = source, identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) } + private fun paymentRequest( + acceptedEndpointIdentifiers: List = listOf(MethodId.Bolt11.rawValue), + ) = PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + counterpartyReceiverPath = SERVER_RECEIVER_PATH, + amountValue = "0.000025", + amountSats = 2_500uL, + paymentReference = "reference", + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = acceptedEndpointIdentifiers, + metadata = "", + ) + private fun privateListDeliveryReport( queuedCounterparties: List = emptyList(), clearedCounterparties: List = emptyList(), diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 777b6191f8..612a5d8205 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -5,7 +5,6 @@ import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport -import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After @@ -20,8 +19,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService -import to.bitkit.services.PaykitContactPaymentResolution -import to.bitkit.services.PaykitPaymentEndpointSource +import to.bitkit.services.PaykitPublicContactPaymentResolution import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService @@ -253,8 +251,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { clock = clock, ) - private fun resolution(vararg endpoints: PaykitResolvedPaymentEndpoint) = PaykitContactPaymentResolution( - privateState = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + private fun resolution(vararg endpoints: PaykitResolvedPaymentEndpoint) = PaykitPublicContactPaymentResolution( payableEndpoints = endpoints.toList(), ) @@ -263,8 +260,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { value: String, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( - counterparty = "pubkycontact", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ee1916e394..7e9f1863ab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -21,7 +21,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test @@ -72,12 +74,17 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestError +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress @@ -107,8 +114,10 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @Suppress("LargeClass") @@ -142,6 +151,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyRepo = mock() private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() + private val paykitPaymentRequestRepo = mock() private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -160,6 +170,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyPublicKey = MutableStateFlow(null) private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) + private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() @@ -212,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) + whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } @@ -299,6 +311,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + paykitPaymentRequestRepo = paykitPaymentRequestRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -339,6 +352,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onAppForegrounded() } + @Test + fun `payment requests refresh periodically only while polling is active`() = test { + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo).refresh() + + sut.stopPaykitPaymentRequestPolling() + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo, never()).refresh() + } + @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" @@ -1475,6 +1509,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `incoming payment request opens the existing confirm flow with its fixed amount`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1paymentrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ), + ) + + pendingPaykitPaymentRequests.value = listOf(request) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals( + ContactPaymentContext(testPublicKey, privateContext, request), + activeContactPaymentContext(), + ) + } + @Test fun `manual send path clears stale contact context`() = test { setActiveContactPaymentContext("pubkycontact") @@ -1499,9 +1564,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private onchain contact payment discards remote address after send`() = test { + fun `private onchain contact payment consumes private list before send`() = test { val address = "bcrt1qprivatecontact" val contactKey = "pubkycontact" + val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever { lightningRepo.sendOnChain( @@ -1513,7 +1579,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = emptyList(), ) }.thenReturn(Result.success("txid")) - setActiveContactPaymentContext(contactKey) + whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(contactKey, privateContext) setSendState( SendUiState( address = address, @@ -1525,7 +1593,77 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).discardRemoteOnchainEndpoints(contactKey, setOf(address)) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) + } + + @Test + fun `incoming payment request is accepted before its private list is consumed`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { paykitPaymentRequestRepo.accept(request) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + .thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + } + + @Test + fun `expired incoming payment request is not submitted`() = test { + val address = "bcrt1qexpiredrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever { paykitPaymentRequestRepo.accept(request) } + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) } @Test @@ -1589,13 +1727,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private lightning contact payment consumes remote list before send`() = test { + fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" val paymentHash = "payment_hash" val contactKey = "pubkycontact" + val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) - setActiveContactPaymentContext(contactKey) + whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(contactKey, privateContext) setSendState( SendUiState( address = bolt11, @@ -1617,83 +1758,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test - fun `private lightning contact payment stops when list consumption fails`() = test { - val bolt11 = "lnbcrt1privatecontact" - val paymentHash = "010203" - val contactKey = "pubkycontact" - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever { privatePaykitRepo.discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) } - .thenReturn(Result.failure(AppError("backup failed"))) - setActiveContactPaymentContext(contactKey) - setSendState( - SendUiState( - address = bolt11, - amount = 1000u, - payMethod = SendMethod.LIGHTNING, - decodedInvoice = lightningInvoice(bolt11, amountSats = 1000u), - ), - ) - - sut.setSendEvent(SendEvent.PayConfirmed) - advanceUntilIdle() - - verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - } - - @Test - fun `private lnurl contact payment stops when list consumption fails`() = test { - val lnurl = lnurlPayData() - val bolt11 = "lnbcrt1privatecontact" - val contactKey = "pubkycontact" - balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever( - lightningRepo.fetchLnurlInvoice( - data = lnurl, - amountMsats = 1_000_000uL, - comment = null, - ), - ).thenReturn(Result.success(lightningInvoice(bolt11, amountSats = 1000u))) - whenever { - privatePaykitRepo.discardRemoteLightningEndpoints( - publicKey = contactKey, - paymentHashes = setOf("010203"), - paymentRequests = setOf(lnurl.uri), - ) - }.thenReturn(Result.failure(AppError("backup failed"))) - setActiveContactPaymentContext(contactKey, lnurl.uri) - setSendState( - SendUiState( - address = lnurl.uri, - amount = 1000u, - payMethod = SendMethod.LIGHTNING, - lnurl = LnurlParams.LnurlPay(lnurl), - ), - ) - - sut.setSendEvent(SendEvent.PayConfirmed) - advanceUntilIdle() - - verify(privatePaykitRepo).discardRemoteLightningEndpoints( - publicKey = contactKey, - paymentHashes = setOf("010203"), - paymentRequests = setOf(lnurl.uri), - ) - verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - } - - @Test - fun `private lightning pending payment consumes decoded invoice`() = test { + fun `private lightning pending payment consumes private list`() = test { val bolt11 = "lnbcrt1pending" val paymentHash = "pending_hash" val contactKey = "pubkycontact" + val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(PaymentPendingException(paymentHash))) - setActiveContactPaymentContext(contactKey) + whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(contactKey, privateContext) setSendState( SendUiState( address = bolt11, @@ -1706,17 +1785,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test - fun `private lightning duplicate payment discards decoded invoice`() = test { + fun `private lightning duplicate payment consumes private list`() = test { val bolt11 = "lnbcrt1duplicate" val contactKey = "pubkycontact" + val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) .thenReturn(Result.failure(AppError("DuplicatePayment"))) - setActiveContactPaymentContext(contactKey) + whenever { privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext) } + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(contactKey, privateContext) setSendState( SendUiState( address = bolt11, @@ -1729,7 +1811,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test @@ -2051,11 +2133,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun setActiveContactPaymentContext( publicKey: String, - paymentRequest: String? = null, + privatePaymentContext: PrivatePaykitPaymentContext? = null, + incomingPaymentRequest: PaykitPaymentRequest? = null, ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true - field.set(sut, ContactPaymentContext(publicKey, paymentRequest)) + field.set(sut, ContactPaymentContext(publicKey, privatePaymentContext, incomingPaymentRequest)) } private fun activeContactPaymentContext(): ContactPaymentContext? { @@ -2102,6 +2185,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { method.isAccessible = true method.invoke(sut) } + + private fun paymentRequest() = PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = testPublicKey, + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.000025", + amountSats = 2_500uL, + paymentReference = "reference", + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), + metadata = "", + ) } private const val SAMROCK_SETUP_URL = diff --git a/changelog.d/next/1098.added.md b/changelog.d/next/1098.added.md new file mode 100644 index 0000000000..94f414e756 --- /dev/null +++ b/changelog.d/next/1098.added.md @@ -0,0 +1 @@ +Incoming Paykit payment requests can now be reviewed and approved through the existing payment flow. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62ea5f1487..7793a973ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc40" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc39" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 3bda03c8371e088f0f50dec015424143bc348277 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 10:47:28 +0200 Subject: [PATCH 2/8] fix: harden paykit request payments --- .../repositories/PaykitPaymentRequestRepo.kt | 15 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 26 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 155 ++++++-- app/src/main/res/values/strings.xml | 1 + .../PaykitPaymentRequestRepoTest.kt | 50 +++ .../repositories/PrivatePaykitRepoTest.kt | 96 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 357 +++++++++++++++++- 7 files changed, 642 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 2b2063d437..a958e6fcf1 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -56,6 +56,14 @@ data class PaykitPaymentRequest( get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true + + fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = + amountMsats == null || amountSats <= ULong.MAX_VALUE / 1000uL && amountMsats == amountSats * 1000uL + + fun acceptsLightningInvoiceAmountSats(amountSats: ULong): Boolean = + amountSats == 0uL || acceptsPaymentAmount(amountSats) + + fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats } sealed class PaykitPaymentRequestError(message: String) : AppError(message) { @@ -105,6 +113,9 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) } + fun isPending(request: PaykitPaymentRequest): Boolean = + !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } + suspend fun clear() { stateGeneration.incrementAndGet() withContext(ioDispatcher) { @@ -206,7 +217,9 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPay if (localRole != PaymentRequestLocalRole.PAYER || state != PaymentRequestLifecycleState.PROPOSED) return null val requestTerms = terms ?: return null if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null - val amountSats = requestTerms.amount.value.toSats() ?: return null + val amountSats = requestTerms.amount.value.toSats() + ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } + ?: return null val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers .filter { MethodId.fromRawValue(it) != null } .distinct() diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index e40aca48cc..35aaa5e439 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -88,6 +88,7 @@ class PrivatePaykitRepo @Inject constructor( 45.seconds, 90.seconds, ) + private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -299,7 +300,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() - beginContactPayment(normalizedKey, paymentRequest = null).getOrThrow() + beginSavedContactPaymentWithRetry(normalizedKey) } } @@ -496,12 +497,6 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (!hasLiveSessionForCurrentProfile()) { - if (paymentRequest != null) throw PrivatePaykitError.PrivateUnavailable - return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() - } - if (paymentRequest == null) refreshPrivateEndpointsBeforePayment(publicKey) - val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET val consumedVersion = ensureState().contacts[publicKey] ?.consumedPrivatePaymentListVersionsByReceiverPath @@ -525,15 +520,30 @@ class PrivatePaykitRepo @Inject constructor( return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } - privatePaymentResult( + val result = privatePaymentResult( publicKey = publicKey, receiverPath = receiverPath, resolution = resolution, acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), ) + if (paymentRequest?.isExpired(clock.now()) == true) { + throw PaykitPaymentRequestError.RequestExpired + } + result } } + private suspend fun beginSavedContactPaymentWithRetry(publicKey: String): PublicPaykitPaymentResult { + refreshPrivateEndpointsBeforePayment(publicKey) + var result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() + for (retryDelay in privatePaymentResolutionRetryDelays) { + if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return result + delay(retryDelay) + result = beginContactPayment(publicKey, paymentRequest = null).getOrThrow() + } + return result + } + private suspend fun refreshPrivateEndpointsBeforePayment(publicKey: String) { if (!canPublishPrivateEndpoints()) return publishLocalEndpoints( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8340c78ee2..465ed9b459 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -60,6 +60,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event @@ -135,6 +136,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException @@ -288,6 +290,7 @@ class AppViewModel @Inject constructor( private val pendingContactPaymentContexts = mutableMapOf() private val presentedPaymentRequestIds = mutableSetOf() private var isPresentingPaymentRequest = false + private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) @@ -609,7 +612,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests() { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return - paykitPaymentRequestRepo.refresh() + paykitPaymentRequestRepo.refresh().onSuccess { presentNextIncomingPaykitPaymentRequest() } } fun startPaykitPaymentRequestPolling() { @@ -630,31 +633,49 @@ class AppViewModel @Inject constructor( private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { - combine(paykitPaymentRequestRepo.pendingRequests, currentSheet) { requests, sheet -> requests to sheet } - .collect { (requests, sheet) -> - presentedPaymentRequestIds.retainAll(requests.mapTo(mutableSetOf()) { it.id }) - if (sheet != null || isPresentingPaymentRequest) return@collect - val request = requests.firstOrNull { it.id !in presentedPaymentRequestIds } ?: return@collect - presentIncomingPaykitPaymentRequest(request) + currentSheet.collect { + if (it == null) presentNextIncomingPaykitPaymentRequest() + } + } + viewModelScope.launch { + paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> + val activeRequest = activeIncomingPaymentRequest() ?: return@collect + if ( + !isSubmittingPaymentRequest && + currentSheet.value is Sheet.Send && + requests.none { it.id == activeRequest.id } + ) { + hideSheet() } + } } } - private suspend fun presentIncomingPaykitPaymentRequest(request: PaykitPaymentRequest) { + private suspend fun presentNextIncomingPaykitPaymentRequest() { + val requests = paykitPaymentRequestRepo.pendingRequests.value + presentedPaymentRequestIds.retainAll(requests.mapTo(mutableSetOf()) { it.id }) + if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return isPresentingPaymentRequest = true - privatePaykitRepo.beginPaymentRequest(request) - .onSuccess { result -> - if (result is PublicPaykitPaymentResult.Opened && currentSheet.value == null) { - presentedPaymentRequestIds += request.id - openContactPayment( - paymentRequest = result.paymentRequest, - publicKey = request.counterparty, - privatePaymentContext = result.privatePaymentContext, - incomingPaymentRequest = request, - ) + try { + for (request in requests.filter { it.id !in presentedPaymentRequestIds }) { + val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() + if (currentSheet.value != null || hasActiveContactPaymentContext()) return + if (result !is PublicPaykitPaymentResult.Opened || !paykitPaymentRequestRepo.isPending(request)) { + continue } + + presentedPaymentRequestIds += request.id + openContactPayment( + paymentRequest = result.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = result.privatePaymentContext, + incomingPaymentRequest = request, + ) + return } - isPresentingPaymentRequest = false + } finally { + isPresentingPaymentRequest = false + } } private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { @@ -1995,8 +2016,10 @@ class AppViewModel @Inject constructor( } val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats - val lnInvoice = extractViableLightningInvoice(invoice.params) val incomingPaymentRequest = activeIncomingPaymentRequest() + val lnInvoice = extractViableLightningInvoice(invoice.params)?.takeIf { + incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(it.amountSatoshis) != false + } val amount = incomingPaymentRequest?.amountSats ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } ?: invoice.amountSatoshis @@ -2133,7 +2156,13 @@ class AppViewModel @Inject constructor( return } - val amount = activeIncomingPaymentRequest()?.amountSats ?: invoice.amountSatoshis + val incomingPaymentRequest = activeIncomingPaymentRequest() + if (incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis) == false) { + rejectMismatchedPaymentRequest() + return + } + + val amount = incomingPaymentRequest?.amountSats ?: invoice.amountSatoshis val quickPayHandled = handleQuickPayIfApplicable( amountSats = amount, invoice = invoice, @@ -2497,15 +2526,12 @@ class AppViewModel @Inject constructor( } @Suppress("LongMethod") - private suspend fun proceedWithPayment() { + private suspend fun proceedWithPayment(contactPaymentContext: ContactPaymentContext?) { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable - acceptIncomingPaymentRequestIfNeeded().onFailure { - toast(it) - return - } + if (!validateAndAcceptIncomingPaymentRequest(contactPaymentContext)) return - consumePrivatePaymentListIfNeeded().onFailure { + consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { toast(it) hideSheet() return @@ -2628,6 +2654,53 @@ class AppViewModel @Inject constructor( } } + private fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false + if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true + if (_sendUiState.value.payMethod != SendMethod.LIGHTNING) return false + + val lightningInvoice = _sendUiState.value.decodedInvoice ?: return false + return !incomingPaymentRequest.acceptsLightningInvoice(lightningInvoice) + } + + private fun PaykitPaymentRequest.acceptsLightningInvoice(invoice: LightningInvoice): Boolean { + val amountMsats = runCatching { Bolt11Invoice.fromStr(invoice.bolt11).amountMilliSatoshis() } + .getOrElse { return false } + return acceptsLightningInvoiceAmountMsats(amountMsats) + } + + private suspend fun validateAndAcceptIncomingPaymentRequest( + contactPaymentContext: ContactPaymentContext?, + ): Boolean { + if ( + (_sendUiState.value.isPaymentRequest && contactPaymentContext?.incomingPaymentRequest == null) || + hasMismatchedIncomingPaymentRequest(contactPaymentContext) + ) { + rejectMismatchedPaymentRequest() + return false + } + + val error = acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).exceptionOrNull() ?: return true + toast(error) + if ( + error is PaykitPaymentRequestError.RequestExpired || + error is PaykitPaymentRequestError.RequestUnavailable + ) { + hideSheet() + } + return false + } + + private fun rejectMismatchedPaymentRequest() { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__toast_payment_failed_title), + description = context.getString(R.string.wallet__payment_request_mismatch), + testTag = "PaymentFailedToast", + ) + hideSheet() + } + private fun getLnurlInvoiceFetchErrorMessage(error: Throwable): String = when (error) { is LnurlPayInvoiceMismatchError -> context.getString(R.string.lightning__order_state__payment_canceled) else -> context.getString(R.string.wallet__error_lnurl_invoice_fetch) @@ -3268,9 +3341,22 @@ class AppViewModel @Inject constructor( private fun onConfirmPay() { Logger.debug("Payment checks confirmed, proceeding…", context = TAG) + if (isSubmittingPaymentRequest) return + + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (_sendUiState.value.isPaymentRequest && contactPaymentContext?.incomingPaymentRequest == null) { + rejectMismatchedPaymentRequest() + return + } + + isSubmittingPaymentRequest = contactPaymentContext?.incomingPaymentRequest != null viewModelScope.launch { - _sendUiState.update { it.copy(shouldConfirmPay = false) } - proceedWithPayment() + try { + _sendUiState.update { it.copy(shouldConfirmPay = false) } + proceedWithPayment(contactPaymentContext) + } finally { + isSubmittingPaymentRequest = false + } } } @@ -3308,17 +3394,14 @@ class AppViewModel @Inject constructor( } } - private suspend fun consumePrivatePaymentListIfNeeded(): Result { - val context = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - ?: return Result.success(Unit) + private suspend fun consumePrivatePaymentListIfNeeded(context: ContactPaymentContext?): Result { + context ?: return Result.success(Unit) val privatePaymentContext = context.privatePaymentContext ?: return Result.success(Unit) return privatePaykitRepo.consumePrivatePaymentList(context.publicKey, privatePaymentContext) } - private suspend fun acceptIncomingPaymentRequestIfNeeded(): Result { - val request = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest - } ?: return Result.success(Unit) + private suspend fun acceptIncomingPaymentRequestIfNeeded(context: ContactPaymentContext?): Result { + val request = context?.incomingPaymentRequest ?: return Result.success(Unit) return paykitPaymentRequestRepo.accept(request) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 478ca60fc0..5e3540d739 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1149,6 +1149,7 @@ Note Received Bitcoin Payment Request + The payment details did not match the request. Payment cancelled. Peer disconnected. Receive Receive Lightning funds diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 3c106dda84..15c74ba88b 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -27,6 +27,7 @@ import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -84,6 +85,44 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } + @Test + fun `refresh rejects amounts outside the app payment range`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + listOf( + paymentRequestRecord(id = "millisatoshi-safe-max", amount = "184467440.73709551"), + paymentRequestRecord(id = "millisatoshi-overflow", amount = "184467440.73709552"), + paymentRequestRecord(id = "long-max", amount = "92233720368.54775807"), + paymentRequestRecord(id = "long-overflow", amount = "92233720368.54775808"), + paymentRequestRecord(id = "ulong-max", amount = "184467440737.09551615"), + ), + ) + + sut.refresh().getOrThrow() + + assertEquals(listOf("millisatoshi-safe-max"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf(ULong.MAX_VALUE / 1000uL), sut.pendingRequests.value.map { it.amountSats }) + } + + @Test + fun `lightning invoice amount must exactly match the request in millisatoshis`() { + val request = PaykitPaymentRequest( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + amountValue = "0.000025", + amountSats = 2_500uL, + paymentReference = "reference", + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + metadata = "", + ) + + assertTrue(request.acceptsLightningInvoiceAmountMsats(null)) + assertTrue(request.acceptsLightningInvoiceAmountMsats(2_500_000uL)) + assertFalse(request.acceptsLightningInvoiceAmountMsats(2_499_999uL)) + assertFalse(request.acceptsLightningInvoiceAmountMsats(2_500_001uL)) + } + @Test fun `refresh drops expired unsupported and non payer requests`() = test { whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( @@ -151,6 +190,17 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `expired request is no longer pending before the expiration job runs`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + advanceTimeBy(1_000) + + assertTrue(!sut.isPending(request)) + } + @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 82ff5c8417..f7fc253097 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -37,6 +37,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -723,7 +724,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment uses public resolution without live Noise session`() = test { + fun `beginSavedContactPayment uses cached private resolution without live SDK session`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus( @@ -731,14 +732,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { liveSessionAvailable = false, ), ) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } - verifyBlocking(paykitSdkService, never()) { - prepareAndResolvePrivateContactPayment(any(), any(), any(), any()) - } + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ), + result, + ) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test @@ -783,7 +792,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment waits for newer private list without public fallback`() = test { + fun `beginSavedContactPayment retries a newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) @@ -794,11 +803,23 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { linkState = LinkedPeerState.LINKED, version = null, ), + resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL), ) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, result) + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ), + result, + ) + verifyBlocking(paykitSdkService, times(2)) { + prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -866,7 +887,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - verifyBlocking(paykitSdkService) { + verifyBlocking(paykitSdkService, times(4)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) } } @@ -928,16 +949,67 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginPaymentRequest never falls back to public resolution without live Noise session`() = test { + fun `beginPaymentRequest uses cached private resolution without live SDK session`() = test { + val request = paymentRequest() whenever(paykitSdkService.identityStatus()).thenReturn( IdentityStatus( publicKey = OWN_KEY, liveSessionAvailable = false, ), ) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, + ), + ) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) + + val result = sut.beginPaymentRequest(request).getOrThrow() + + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = SERVER_PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + ), + result, + ) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginPaymentRequest rechecks expiration after private resolution`() = test { + val request = paymentRequest() + whenever(clock.now()).thenReturn( + Instant.fromEpochSeconds(NOW_SECONDS), + Instant.fromEpochSeconds(NOW_SECONDS + 61), + ) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, + ), + ) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) - assertFailsWith { - sut.beginPaymentRequest(paymentRequest()).getOrThrow() + assertFailsWith { + sut.beginPaymentRequest(request).getOrThrow() } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 7e9f1863ab..19b0804fd9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14,6 +14,7 @@ import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -37,6 +38,7 @@ import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer 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 org.robolectric.RobolectricTestRunner @@ -224,6 +226,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } @@ -373,6 +376,165 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).refresh() } + @Test + fun `payment request waiting for a newer private list is retried on refresh`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1updatedpaymentrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + assertNull(sut.currentSheet.value) + + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `active contact payment prevents presenting another payment request`() = test { + val activeRequest = paymentRequest() + val pendingRequest = activeRequest.copy(paymentRequestId = "next-request") + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = activeRequest) + pendingPaykitPaymentRequests.value = listOf(pendingRequest) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, never()).beginPaymentRequest(pendingRequest) + assertEquals(activeRequest, activeContactPaymentContext()?.incomingPaymentRequest) + } + + @Test + fun `request removed during endpoint resolution is not presented`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = "lnbcrt1stale", + privatePaymentContext = privateContext, + ), + ), + ) + whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + } + + @Test + fun `contact payment opened during request resolution is not overwritten`() = test { + val request = paymentRequest() + val manualContext = ContactPaymentContext("pubkymanual") + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenAnswer { + setActiveContactPaymentContext(manualContext.publicKey) + PublicPaykitPaymentResult.Opened( + paymentRequest = "lnbcrt1incoming", + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ) + } + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + assertEquals(manualContext, activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + } + + @Test + fun `unavailable request does not starve a later payable request`() = test { + val unavailableRequest = paymentRequest() + val payableRequest = unavailableRequest.copy(paymentRequestId = "payable-request") + val bolt11 = "lnbcrt1payablerequest" + whenever { privatePaykitRepo.beginPaymentRequest(unavailableRequest) } + .thenReturn(Result.success(PublicPaykitPaymentResult.NoEndpoint)) + whenever { privatePaykitRepo.beginPaymentRequest(payableRequest) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(unavailableRequest, payableRequest) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo).beginPaymentRequest(unavailableRequest) + verify(privatePaykitRepo).beginPaymentRequest(payableRequest) + assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `cancelled request resolution releases the presentation guard`() = test { + val request = paymentRequest() + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenThrow(CancellationException()) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + assertFalse(isPresentingPaymentRequest()) + } + @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" @@ -1527,9 +1689,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ), ) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) - advanceUntilIdle() + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.amountSats, sut.sendUiState.value.amount) @@ -1540,6 +1706,111 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `incoming payment request closes when it is no longer pending`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1removedpaymentrequest" + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ), + ) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(request) + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `duplicate payment request confirmation submits only once`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { paykitPaymentRequestRepo.accept(request) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + .thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + runCurrent() + sut.setSendEvent(SendEvent.PayConfirmed) + runCurrent() + advanceUntilIdle() + + verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(lightningRepo).sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + } + + @Test + fun `incoming payment request rejects a mismatched fixed invoice before confirmation`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1mismatchedconfirmation" + stubLightningScan(bolt11 = bolt11, amountSats = 2_501uL) + + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + incomingPaymentRequest = request, + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + } + @Test fun `manual send path clears stale contact context`() = test { setActiveContactPaymentContext("pubkycontact") @@ -1632,6 +1903,84 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) } + @Test + fun `incoming payment request rejects mismatched fixed invoice amount before acceptance`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1mismatchedrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = bolt11, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 2_501uL), + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `incoming payment request rejects changed onchain amount before acceptance`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qchangedrequest", + amount = 2_501uL, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) + } + + @Test + fun `incoming payment request rejects changed amount for amountless invoice`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1changedrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = bolt11, + amount = 2_501uL, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, amountSats = 0uL), + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + @Test fun `expired incoming payment request is not submitted`() = test { val address = "bcrt1qexpiredrequest" @@ -2147,6 +2496,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { return field.get(sut) as ContactPaymentContext? } + private fun isPresentingPaymentRequest(): Boolean { + val field = AppViewModel::class.java.getDeclaredField("isPresentingPaymentRequest") + field.isAccessible = true + return field.getBoolean(sut) + } + @Suppress("UNCHECKED_CAST") private fun pendingContactPaymentContext(paymentHash: String): ContactPaymentContext? { val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") From d3ed3c0b85325ad5d6aec29f80d39a4926af3598 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 3 Aug 2026 08:40:36 +0200 Subject: [PATCH 3/8] fix: back off paykit request retries --- .../repositories/PaykitPaymentRequestRepo.kt | 9 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 38 +----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 116 +++++++++++++----- .../PaykitPaymentRequestRepoTest.kt | 4 - .../repositories/PrivatePaykitRepoTest.kt | 2 - .../viewmodels/AppViewModelSendFlowTest.kt | 26 ++-- 6 files changed, 109 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index a958e6fcf1..97b00b9d5e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -8,9 +8,7 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import to.bitkit.async.appScope import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyPublicKeyFormat @@ -47,10 +46,8 @@ data class PaykitPaymentRequest( val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, - val paymentReference: String, val expiresAt: Instant?, val acceptedPaymentEndpointIdentifiers: List, - val metadata: String, ) { val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -83,7 +80,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val operationMutex = Mutex() private val stateGeneration = AtomicLong() - private val repoScope = CoroutineScope(SupervisorJob() + ioDispatcher) + private val repoScope = appScope(ioDispatcher, TAG) private var expirationJob: Job? = null private val _pendingRequests = MutableStateFlow>(emptyList()) val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() @@ -236,10 +233,8 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPay counterpartyReceiverPath = counterpartyReceiverPath, amountValue = requestTerms.amount.value, amountSats = amountSats, - paymentReference = requestTerms.paymentReference.exportText(), expiresAt = expiresAt, acceptedPaymentEndpointIdentifiers = endpoints, - metadata = requestTerms.metadata.exportText(), ) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 35aaa5e439..1a88eb412d 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -360,27 +360,6 @@ class PrivatePaykitRepo @Inject constructor( } } - suspend fun discardRemoteOnchainEndpoints( - publicKey: String, - addresses: Set, - ): Result = withContext(serializedDispatcher) { - runSuspendCatching { - if (addresses.isEmpty()) return@runSuspendCatching - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching - val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val filteredEntries = contactState.remoteEndpoints.filterNot { - shouldDiscardRemoteOnchainEntry(it, addresses) - } - if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - - persistConsumedRemotePaymentList( - publicKey = normalizedKey, - contactState = contactState, - receiverPath = PaykitReceiverPaths.WALLET, - ).getOrThrow() - } - } - suspend fun handleReceivedPayment(paymentHash: String): Result = refreshReceivedPrivateInvoices(setOf(paymentHash), reason = "invoice rotation") @@ -508,12 +487,7 @@ class PrivatePaykitRepo @Inject constructor( consumedVersion = consumedVersion, amount = amount, allowPublicResolution = paymentRequest == null, - ) - ?: if (paymentRequest == null) { - return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() - } else { - throw PrivatePaykitError.PrivateUnavailable - } + ) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val resolution = prepared.resolution val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { @@ -601,6 +575,7 @@ class PrivatePaykitRepo @Inject constructor( val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) val paymentListVersion = resolution.privatePaymentListVersion if (privatePayable.isNotEmpty() && paymentListVersion != null) { + Logger.info("Opened private Paykit payment for '${redacted(publicKey)}'", context = TAG) return PublicPaykitPaymentResult.Opened( paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), @@ -1450,15 +1425,6 @@ class PrivatePaykitRepo @Inject constructor( } } - 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.sharesPrivatePaykitEndpoints && diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 465ed9b459..a4cf8539e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -292,6 +292,8 @@ class AppViewModel @Inject constructor( private var isPresentingPaymentRequest = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null + private val paymentRequestPresentationRetryAttempts = mutableMapOf() + private val paymentRequestPresentationRetryJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -610,18 +612,30 @@ class AppViewModel @Inject constructor( } } - private suspend fun refreshIncomingPaykitPaymentRequests() { - if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return - paykitPaymentRequestRepo.refresh().onSuccess { presentNextIncomingPaykitPaymentRequest() } + private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { + if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false + val previousRequests = paykitPaymentRequestRepo.pendingRequests.value + return paykitPaymentRequestRepo.refresh().fold( + onSuccess = { + presentNextIncomingPaykitPaymentRequest() + paykitPaymentRequestRepo.pendingRequests.value != previousRequests + }, + onFailure = { false }, + ) } fun startPaykitPaymentRequestPolling() { if (paykitPaymentRequestPollingJob?.isActive == true) return paykitPaymentRequestPollingJob = viewModelScope.launch { + var refreshIntervalIndex = 0 while (true) { - delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) - refreshIncomingPaykitPaymentRequests() + delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS[refreshIntervalIndex]) + refreshIntervalIndex = if (refreshIncomingPaykitPaymentRequests()) { + 0 + } else { + (refreshIntervalIndex + 1).coerceAtMost(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS.lastIndex) + } } } } @@ -629,6 +643,9 @@ class AppViewModel @Inject constructor( fun stopPaykitPaymentRequestPolling() { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null + paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } + paymentRequestPresentationRetryJobs.clear() + paymentRequestPresentationRetryAttempts.clear() } private fun observeIncomingPaykitPaymentRequests() { @@ -653,31 +670,67 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { val requests = paykitPaymentRequestRepo.pendingRequests.value - presentedPaymentRequestIds.retainAll(requests.mapTo(mutableSetOf()) { it.id }) + retainPaymentRequestPresentationState(requests) if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return isPresentingPaymentRequest = true try { - for (request in requests.filter { it.id !in presentedPaymentRequestIds }) { - val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() - if (currentSheet.value != null || hasActiveContactPaymentContext()) return - if (result !is PublicPaykitPaymentResult.Opened || !paykitPaymentRequestRepo.isPending(request)) { - continue - } - - presentedPaymentRequestIds += request.id - openContactPayment( - paymentRequest = result.paymentRequest, - publicKey = request.counterparty, - privatePaymentContext = result.privatePaymentContext, - incomingPaymentRequest = request, - ) - return + for (request in requests.filter { + it.id !in presentedPaymentRequestIds && paymentRequestPresentationRetryJobs[it.id]?.isActive != true + }) { + if (openIncomingPaymentRequestIfAvailable(request)) return } } finally { isPresentingPaymentRequest = false } } + private suspend fun openIncomingPaymentRequestIfAvailable(request: PaykitPaymentRequest): Boolean { + val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() + if (currentSheet.value != null || hasActiveContactPaymentContext()) return true + if (result !is PublicPaykitPaymentResult.Opened || !paykitPaymentRequestRepo.isPending(request)) { + if (paykitPaymentRequestRepo.isPending(request)) deferPaymentRequestPresentation(request) + return false + } + + clearPaymentRequestPresentationRetry(request.id) + presentedPaymentRequestIds += request.id + openContactPayment( + paymentRequest = result.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = result.privatePaymentContext, + incomingPaymentRequest = request, + ) + return true + } + + private fun deferPaymentRequestPresentation(request: PaykitPaymentRequest) { + val attempt = paymentRequestPresentationRetryAttempts[request.id] ?: 0 + val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS[ + attempt.coerceAtMost(PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.lastIndex) + ] + paymentRequestPresentationRetryAttempts[request.id] = attempt + 1 + paymentRequestPresentationRetryJobs.remove(request.id)?.cancel() + paymentRequestPresentationRetryJobs[request.id] = viewModelScope.launch { + delay(retryDelay) + paymentRequestPresentationRetryJobs.remove(request.id) + presentNextIncomingPaykitPaymentRequest() + } + } + + private fun retainPaymentRequestPresentationState(requests: List) { + val requestIds = requests.mapTo(mutableSetOf()) { it.id } + presentedPaymentRequestIds.retainAll(requestIds) + paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) + paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { + paymentRequestPresentationRetryJobs.remove(it)?.cancel() + } + } + + private fun clearPaymentRequestPresentationRetry(requestId: PaykitPaymentRequestId) { + paymentRequestPresentationRetryAttempts.remove(requestId) + paymentRequestPresentationRetryJobs.remove(requestId)?.cancel() + } + private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return @@ -2654,7 +2707,7 @@ class AppViewModel @Inject constructor( } } - private fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { + private suspend fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true if (_sendUiState.value.payMethod != SendMethod.LIGHTNING) return false @@ -2663,11 +2716,12 @@ class AppViewModel @Inject constructor( return !incomingPaymentRequest.acceptsLightningInvoice(lightningInvoice) } - private fun PaykitPaymentRequest.acceptsLightningInvoice(invoice: LightningInvoice): Boolean { - val amountMsats = runCatching { Bolt11Invoice.fromStr(invoice.bolt11).amountMilliSatoshis() } - .getOrElse { return false } - return acceptsLightningInvoiceAmountMsats(amountMsats) - } + private suspend fun PaykitPaymentRequest.acceptsLightningInvoice(invoice: LightningInvoice): Boolean = + withContext(bgDispatcher) { + val amountMsats = runCatching { Bolt11Invoice.fromStr(invoice.bolt11).amountMilliSatoshis() } + .getOrElse { return@withContext false } + acceptsLightningInvoiceAmountMsats(amountMsats) + } private suspend fun validateAndAcceptIncomingPaymentRequest( contactPaymentContext: ContactPaymentContext?, @@ -3636,7 +3690,13 @@ class AppViewModel @Inject constructor( 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 PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL = 30.seconds + private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS = listOf(30.seconds, 60.seconds, 120.seconds) + private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = listOf( + 30.seconds, + 60.seconds, + 120.seconds, + 300.seconds, + ) private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 15c74ba88b..47d388b48a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -80,8 +80,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = sut.pendingRequests.value.single() assertEquals(100_000uL, request.amountSats) - assertEquals("invoice-123", request.paymentReference) - assertEquals("""{"order":"123"}""", request.metadata) assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } @@ -111,10 +109,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { counterpartyReceiverPath = PaykitReceiverPaths.SERVER, amountValue = "0.000025", amountSats = 2_500uL, - paymentReference = "reference", expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), - metadata = "", ) assertTrue(request.acceptsLightningInvoiceAmountMsats(null)) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index f7fc253097..102d778a0a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1091,10 +1091,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { counterpartyReceiverPath = SERVER_RECEIVER_PATH, amountValue = "0.000025", amountSats = 2_500uL, - paymentReference = "reference", expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), acceptedPaymentEndpointIdentifiers = acceptedEndpointIdentifiers, - metadata = "", ) private fun privateListDeliveryReport( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 19b0804fd9..e9ee711eab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -264,8 +264,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), 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()) } @@ -366,18 +364,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() + verify(paykitPaymentRequestRepo).refresh() + clearInvocations(paykitPaymentRequestRepo) + + advanceTimeBy(59.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() verify(paykitPaymentRequestRepo).refresh() sut.stopPaykitPaymentRequestPolling() clearInvocations(paykitPaymentRequestRepo) - advanceTimeBy(30.seconds.inWholeMilliseconds) + advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo, never()).refresh() } @Test - fun `payment request waiting for a newer private list is retried on refresh`() = test { + fun `payment request waiting for a newer private list is retried after backoff`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1updatedpaymentrequest" val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) @@ -403,8 +410,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) - advanceTimeBy(30.seconds.inWholeMilliseconds) + advanceTimeBy(29.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + + advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() sut.stopPaykitPaymentRequestPolling() @@ -2071,8 +2083,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) confirmCurrentPayment() - - verify(privatePaykitRepo, never()).discardRemoteOnchainEndpoints(any(), any()) } @Test @@ -2547,10 +2557,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { counterpartyReceiverPath = "bitkit/server", amountValue = "0.000025", amountSats = 2_500uL, - paymentReference = "reference", expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), - metadata = "", ) } From 796bc2b105ce60c47175ac69f8b7f26711025812 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 3 Aug 2026 09:35:58 +0200 Subject: [PATCH 4/8] fix: preserve paykit rc39 integration --- .../bitkit/repositories/PrivatePaykitRepo.kt | 105 ++---------------- .../to/bitkit/services/PaykitSdkService.kt | 64 +++-------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 10 +- .../repositories/PrivatePaykitRepoTest.kt | 14 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 48 +------- 5 files changed, 32 insertions(+), 209 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 1a88eb412d..eda1ad570a 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -72,9 +72,6 @@ class PrivatePaykitRepo @Inject constructor( companion object { private const val TAG = "PrivatePaykitRepo" private const val MAX_RECEIVED_INVOICE_HASHES_PER_CONTACT = 100 - - /** Identifies Bitkit's wrapper around the Paykit SDK backup state. */ - private const val BACKUP_STATE_PREFIX = "bitkit-paykit-v1:" private val privateInvoiceExpiry = 24.hours private val invoiceRefreshBuffer = 30.minutes @@ -340,23 +337,19 @@ class PrivatePaykitRepo @Inject constructor( suspend fun discardRemoteLightningEndpoints( publicKey: String, paymentHashes: Set, - paymentRequests: Set = emptySet(), ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (paymentHashes.isEmpty() && paymentRequests.isEmpty()) return@runSuspendCatching + if (paymentHashes.isEmpty()) return@runSuspendCatching val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val normalizedHashes = paymentHashes.map { it.lowercase() }.toSet() val filteredEntries = contactState.remoteEndpoints.filterNot { - shouldDiscardRemoteLightningEntry(it, normalizedHashes, paymentRequests) + shouldDiscardRemoteLightningEntry(it, normalizedHashes) } if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - persistConsumedRemotePaymentList( - publicKey = normalizedKey, - contactState = contactState, - receiverPath = PaykitReceiverPaths.WALLET, - ).getOrThrow() + contactState.remoteEndpoints = filteredEntries + persistState(markWalletBackup = true) } } @@ -453,9 +446,9 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { clearPendingMessageDrainRetries() + state = PrivatePaykitState() knownSavedContactKeys.clear() if (backup == null) { - state = PrivatePaykitState() paykitSdkService.clearState() } else { val decoded = json.decodeFromString(backup) @@ -1170,51 +1163,12 @@ class PrivatePaykitRepo @Inject constructor( return "$publicKey:$receiverPath:${endpoint.methodId.rawValue}:$payloadHashPrefix" } - private suspend fun cacheResolvedPrivateEndpoints( - publicKey: String, - receiverPath: String, - privatePaymentListVersion: ULong?, - endpoints: List, - ) { + private suspend fun cacheResolvedPrivateEndpoints(publicKey: String, endpoints: List) { val contactState = ensureState().contacts.getOrPut(publicKey) { ContactState() } contactState.remoteEndpoints = endpoints.map { StoredPaymentEntry(it.methodId.rawValue, it.rawPayload) } - contactState.remotePaymentListVersionsByReceiverPath = if (privatePaymentListVersion == null) { - contactState.remotePaymentListVersionsByReceiverPath - receiverPath - } else { - contactState.remotePaymentListVersionsByReceiverPath + (receiverPath to privatePaymentListVersion) - } persistState(markWalletBackup = true) } - private fun ContactState.consumeRemotePaymentList(receiverPath: String) { - val version = remotePaymentListVersionsByReceiverPath[receiverPath] - remoteEndpoints = emptyList() - remotePaymentListVersionsByReceiverPath = remotePaymentListVersionsByReceiverPath - receiverPath - if (version != null) { - consumedPaymentListVersionsByReceiverPath = - consumedPaymentListVersionsByReceiverPath + (receiverPath to version) - } - } - - private suspend fun persistConsumedRemotePaymentList( - publicKey: String, - contactState: ContactState, - receiverPath: String, - ): Result { - val previousState = contactState.copy() - contactState.consumeRemotePaymentList(receiverPath) - return runSuspendCatching { - persistState(markWalletBackup = true) - }.onFailure { - state?.contacts?.set(publicKey, previousState) - } - } - - private suspend fun consumedPaymentListVersion(publicKey: String) = ensureState() - .contacts[publicKey] - ?.consumedPaymentListVersionsByReceiverPath - ?.get(PaykitReceiverPaths.WALLET) - private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { runSuspendCatching { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) @@ -1375,7 +1329,7 @@ class PrivatePaykitRepo @Inject constructor( } if (staleLightningHashes.isNotEmpty()) { - pruneRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { + discardRemoteLightningEndpoints(publicKey, staleLightningHashes).onFailure { if (it is CancellationException) throw it Logger.warn( "Failed to discard already-attempted private Paykit invoice for '${redacted(publicKey)}'", @@ -1387,42 +1341,14 @@ class PrivatePaykitRepo @Inject constructor( return reusable } - private suspend fun pruneRemoteLightningEndpoints( - publicKey: String, - paymentHashes: Set, - ): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val normalizedKey = normalizedPublicKey(publicKey) ?: return@runSuspendCatching - val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching - val filteredEntries = contactState.remoteEndpoints.filterNot { - shouldDiscardRemoteLightningEntry(it, paymentHashes, emptySet()) - } - if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - - val previousState = contactState.copy() - contactState.remoteEndpoints = filteredEntries - runSuspendCatching { - persistState(markWalletBackup = true) - }.onFailure { - state?.contacts?.set(normalizedKey, previousState) - }.getOrThrow() - } - } - private suspend fun shouldDiscardRemoteLightningEntry( entry: StoredPaymentEntry, paymentHashes: Set, - paymentRequests: Set, ): Boolean { + if (entry.methodId != MethodId.Bolt11.rawValue) return false val endpoint = PublicPaykitRepo.parseEndpoint(entry.methodId, entry.endpointData) ?: return false - return when (endpoint.methodId) { - MethodId.Bolt11 -> { - val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false - paymentHash in paymentHashes - } - MethodId.Lnurl -> endpoint.value in paymentRequests - else -> false - } + val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false + return paymentHash in paymentHashes } private suspend fun canPublishPrivateEndpoints(): Boolean { @@ -1434,10 +1360,9 @@ class PrivatePaykitRepo @Inject constructor( lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasLiveSessionForCurrentProfile(): Boolean { + private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean { pubkyService.currentPublicKey() ?: return false - val status = paykitSdkService.identityStatus() ?: return false - return status.liveSessionAvailable + return paykitSdkService.hasPrivatePaymentAccess() } private suspend fun isContactSharingCleanupPending(): Boolean = @@ -1591,9 +1516,3 @@ class PrivatePaykitRepo @Inject constructor( _backupStateVersion.update { it + 1 } } } - -@Serializable -private data class PrivatePaykitBackupState( - val sdkState: String, - val consumedPaymentListVersionsByContact: Map> = emptyMap(), -) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5d030c8bba..add30a715e 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -76,6 +76,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.utils.AppError +import to.bitkit.utils.Logger import java.security.MessageDigest import java.util.UUID import javax.crypto.Mac @@ -97,15 +98,8 @@ data class PaykitPrivateContactPaymentResolution( data class PaykitPublicContactPaymentResolution( val payableEndpoints: List, - val privatePaymentListVersion: ULong? = null, - val publicResolutionError: Throwable? = null, ) -enum class PaykitPaymentEndpointSource { - PRIVATE_PAYMENT_LIST, - PUBLIC_PAYMENT_ENDPOINT, -} - data class PaykitResolvedPaymentEndpoint( val identifier: String, val payload: String, @@ -157,7 +151,9 @@ class PaykitSdkService @Inject constructor( try { PaykitAndroid.initializeOrThrow(context) operationMutex.withLock { - handle().initialize() + val handle = handle() + handle.initialize() + publishReceiverMarkerIfLiveSessionAvailable(handle) } isSetup.complete(Unit) } catch (t: Throwable) { @@ -475,14 +471,7 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - handle.publishPaykitReceiverMarker( - PaykitReceiverCapabilities( - privatePayments = sessionProvider.hasSessionAccess(), - paymentRequests = false, - receipts = false, - outgoingPayments = true, - ), - ) + handle.publishPaykitReceiverMarker(receiverCapabilities()) } } } @@ -597,7 +586,7 @@ class PaykitSdkService @Inject constructor( amount: PaymentAmountContext? = null, ): PaykitPreparedPrivateContactPayment { isSetup.await() - val (privateResolution, publicResolution) = operationMutex.withLock { + val prepared = operationMutex.withLock { withStateRevisionTracking { handle -> handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, @@ -605,15 +594,7 @@ class PaykitSdkService @Inject constructor( amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, - ).resolution - val publicResolution = if (includePublicEndpoints) { - runSuspendCatching { - handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) - } - } else { - null - } - privateResolution to publicResolution + ) } } return PaykitPreparedPrivateContactPayment( @@ -653,24 +634,7 @@ class PaykitSdkService @Inject constructor( identifier = it.identifier, payload = it.target.payload.exportText(), ) - } + publicResolution?.resolvedEndpoints().orEmpty(), - privatePaymentListVersion = privatePaymentListVersion, - publicResolutionError = publicResolutionError, - ) - } - - private fun PublicContactPaymentResolution.toPaykitContactPaymentResolution() = - PaykitContactPaymentResolution( - privateState = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, - payableEndpoints = resolvedEndpoints(), - ) - - private fun PublicContactPaymentResolution.resolvedEndpoints() = payableEndpoints.map { - PaykitResolvedPaymentEndpoint( - counterparty = it.counterparty, - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - identifier = it.identifier, - payload = it.target.payload.exportText(), + }, ) suspend fun exportBackupState(): String { @@ -776,7 +740,7 @@ class PaykitSdkService @Inject constructor( private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { - val capabilities = receiverCapabilities(handle) + val capabilities = receiverCapabilities() if (capabilities.privatePayments) { handle.publishPaykitReceiverMarker(capabilities) } @@ -785,11 +749,11 @@ class PaykitSdkService @Inject constructor( } } - private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { - val status = handle.identityStatus() + private fun receiverCapabilities(): PaykitReceiverCapabilities { + val hasPrivatePaymentAccess = sessionProvider.hasSessionAccess() return PaykitReceiverCapabilities( - privatePayments = status?.liveSessionAvailable == true, - paymentRequests = status?.liveSessionAvailable == true, + privatePayments = hasPrivatePaymentAccess, + paymentRequests = hasPrivatePaymentAccess, receipts = false, outgoingPayments = true, ) @@ -847,6 +811,8 @@ class PaykitSdkService @Inject constructor( this?.capabilities?.let { it.privatePayments && it.outgoingPayments } == true companion object { + private const val TAG = "PaykitSdkService" + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a4cf8539e9..ed4a5c6a9b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2617,13 +2617,7 @@ class AppViewModel @Inject constructor( SendMethod.ONCHAIN -> { val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags - val contactPublicKey = activeContactPaymentPublicKey() - - discardContactOnchainEndpoint(contactPublicKey, address) - .fold( - onSuccess = { sendOnchain(address, amount, tags = tags) }, - onFailure = { Result.failure(it) }, - ) + sendOnchain(address, amount, tags = tags) .onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( @@ -2658,8 +2652,6 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags var createdMetadataPaymentId: String? = null - val contactPublicKey = activeContactPaymentPublicKey() - val contactPaymentRequest = activeContactPaymentRequest() // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 102d778a0a..4763db2b0a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -726,12 +726,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginSavedContactPayment uses cached private resolution without live SDK session`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever(paykitSdkService.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - liveSessionAvailable = false, - ), - ) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) @@ -951,12 +946,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginPaymentRequest uses cached private resolution without live SDK session`() = test { val request = paymentRequest() - whenever(paykitSdkService.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - liveSessionAvailable = false, - ), - ) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { paykitSdkService.prepareAndResolvePrivateContactPayment( eq(CONTACT_KEY), diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e9ee711eab..357294fb8b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10,7 +10,6 @@ import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice -import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf @@ -262,7 +261,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } .thenReturn(null) - whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any(), any()) } + whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) .thenReturn(Result.failure(Exception("not mocked"))) @@ -2027,38 +2026,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } - @Test - fun `private onchain contact payment stops when list consumption fails`() = test { - val address = "bcrt1qprivatecontact" - val contactKey = "pubkycontact" - balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(contactKey, setOf(address)) } - .thenReturn(Result.failure(AppError("backup failed"))) - setActiveContactPaymentContext(contactKey) - setSendState( - SendUiState( - address = address, - amount = 1000u, - payMethod = SendMethod.ONCHAIN, - speed = TransactionSpeed.Medium, - ), - ) - - confirmCurrentPayment() - - verify(lightningRepo, never()).sendOnChain( - address = any(), - sats = any(), - speed = anyOrNull(), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = any(), - channelId = anyOrNull(), - isMaxAmount = any(), - tags = any(), - ) - } - @Test fun `non-contact onchain payment does not discard private endpoint`() = test { val address = "bcrt1qpublicpayment" @@ -2200,7 +2167,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any(), any()) + verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) } @Test @@ -2416,17 +2383,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payeeNodeId = null, ) - private fun lnurlPayData() = LnurlPayData( - uri = "lnurl1private", - callback = "https://example.com/callback", - minSendable = 1_000uL, - maxSendable = 100_000_000uL, - metadataStr = "[[\"text/plain\",\"test\"]]", - commentAllowed = null, - allowsNostr = false, - nostrPubkey = null, - ) - private suspend fun enablePublicPaykitSharing() { whenever { publicPaykitRepo.syncCurrentPublishedEndpoints(any(), any()) }.thenReturn(Result.success(Unit)) walletState.value = WalletState(onchainAddress = "bc1qtest") From 5866dc2efa64f2c9636b6747139d9a633f8600bf Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 4 Aug 2026 08:46:04 +0200 Subject: [PATCH 5/8] fix: harden payment request handling --- .../repositories/PaykitPaymentRequestRepo.kt | 3 +- .../bitkit/repositories/PrivatePaykitRepo.kt | 8 +-- .../screens/contacts/AddContactViewModel.kt | 2 +- .../contacts/ContactDetailViewModel.kt | 2 +- .../send/SendContactSelectViewModel.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 57 +++++++++------ app/src/main/res/values/strings.xml | 1 + .../repositories/PrivatePaykitRepoTest.kt | 9 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 69 +++++++++++++++++-- 9 files changed, 116 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 97b00b9d5e..999a72349b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -22,6 +22,7 @@ import to.bitkit.async.appScope import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -55,7 +56,7 @@ data class PaykitPaymentRequest( fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = - amountMsats == null || amountSats <= ULong.MAX_VALUE / 1000uL && amountMsats == amountSats * 1000uL + amountMsats == null || amountMsats == satsToMsat(amountSats) fun acceptsLightningInvoiceAmountSats(amountSats: ULong): Boolean = amountSats == 0uL || acceptsPaymentAmount(amountSats) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index eda1ad570a..a5cc916858 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1360,10 +1360,10 @@ class PrivatePaykitRepo @Inject constructor( lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean { - pubkyService.currentPublicKey() ?: return false - return paykitSdkService.hasPrivatePaymentAccess() - } + private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { + pubkyService.currentPublicKey() ?: return@runSuspendCatching false + paykitSdkService.hasPrivatePaymentAccess() + }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = cacheStore.data.first().cleanupPending diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index e74ce7b0b3..8abd2e7b63 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -127,7 +127,7 @@ class AddContactViewModel @Inject constructor( PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> - showPayError(R.string.slashtags__error_pay_empty_msg) + showPayError(R.string.slashtags__error_pay_waiting_msg) } } .onFailure { 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 e3ee5d0503..9f0c2d0cbd 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 @@ -118,7 +118,7 @@ class ContactDetailViewModel @Inject constructor( PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> - showPayError(R.string.slashtags__error_pay_empty_msg) + showPayError(R.string.slashtags__error_pay_waiting_msg) } } .onFailure { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index 201ef5e9d7..f33ad7f686 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -82,7 +82,7 @@ class SendContactSelectViewModel @Inject constructor( PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> - showPayError(R.string.slashtags__error_pay_empty_msg) + showPayError(R.string.slashtags__error_pay_waiting_msg) } } .onFailure { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ed4a5c6a9b..c62b391f2b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -674,21 +674,25 @@ class AppViewModel @Inject constructor( if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return isPresentingPaymentRequest = true try { - for (request in requests.filter { - it.id !in presentedPaymentRequestIds && paymentRequestPresentationRetryJobs[it.id]?.isActive != true + for (request in requests.filter { request -> + val retryAttempts = paymentRequestPresentationRetryAttempts[request.id] ?: 0 + request.id !in presentedPaymentRequestIds && + retryAttempts <= PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.size && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true }) { - if (openIncomingPaymentRequestIfAvailable(request)) return + if (presentIncomingPaymentRequestOrStop(request)) return } } finally { isPresentingPaymentRequest = false } } - private suspend fun openIncomingPaymentRequestIfAvailable(request: PaykitPaymentRequest): Boolean { + private suspend fun presentIncomingPaymentRequestOrStop(request: PaykitPaymentRequest): Boolean { val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() if (currentSheet.value != null || hasActiveContactPaymentContext()) return true - if (result !is PublicPaykitPaymentResult.Opened || !paykitPaymentRequestRepo.isPending(request)) { - if (paykitPaymentRequestRepo.isPending(request)) deferPaymentRequestPresentation(request) + val isPending = paykitPaymentRequestRepo.isPending(request) + if (result !is PublicPaykitPaymentResult.Opened || !isPending) { + if (isPending) deferPaymentRequestPresentation(request) return false } @@ -705,10 +709,8 @@ class AppViewModel @Inject constructor( private fun deferPaymentRequestPresentation(request: PaykitPaymentRequest) { val attempt = paymentRequestPresentationRetryAttempts[request.id] ?: 0 - val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS[ - attempt.coerceAtMost(PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.lastIndex) - ] paymentRequestPresentationRetryAttempts[request.id] = attempt + 1 + val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.getOrNull(attempt) ?: return paymentRequestPresentationRetryJobs.remove(request.id)?.cancel() paymentRequestPresentationRetryJobs[request.id] = viewModelScope.launch { delay(retryDelay) @@ -2582,11 +2584,15 @@ class AppViewModel @Inject constructor( private suspend fun proceedWithPayment(contactPaymentContext: ContactPaymentContext?) { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable - if (!validateAndAcceptIncomingPaymentRequest(contactPaymentContext)) return + if (!validateIncomingPaymentRequest(contactPaymentContext)) return consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { - toast(it) - hideSheet() + handlePaymentPreparationFailure(contactPaymentContext, it) + return + } + + acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + handlePaymentPreparationFailure(contactPaymentContext, it) return } @@ -2715,26 +2721,23 @@ class AppViewModel @Inject constructor( acceptsLightningInvoiceAmountMsats(amountMsats) } - private suspend fun validateAndAcceptIncomingPaymentRequest( + private suspend fun validateIncomingPaymentRequest( contactPaymentContext: ContactPaymentContext?, ): Boolean { + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest if ( - (_sendUiState.value.isPaymentRequest && contactPaymentContext?.incomingPaymentRequest == null) || + (_sendUiState.value.isPaymentRequest && incomingPaymentRequest == null) || hasMismatchedIncomingPaymentRequest(contactPaymentContext) ) { rejectMismatchedPaymentRequest() return false } - - val error = acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).exceptionOrNull() ?: return true - toast(error) - if ( - error is PaykitPaymentRequestError.RequestExpired || - error is PaykitPaymentRequestError.RequestUnavailable - ) { + if (incomingPaymentRequest != null && !paykitPaymentRequestRepo.isPending(incomingPaymentRequest)) { + toast(PaykitPaymentRequestError.RequestUnavailable) hideSheet() + return false } - return false + return true } private fun rejectMismatchedPaymentRequest() { @@ -3451,6 +3454,16 @@ class AppViewModel @Inject constructor( return paykitPaymentRequestRepo.accept(request) } + private fun handlePaymentPreparationFailure(context: ContactPaymentContext?, error: Throwable) { + toast(error) + val request = context?.incomingPaymentRequest + if (request != null && paykitPaymentRequestRepo.isPending(request)) { + presentedPaymentRequestIds.remove(request.id) + deferPaymentRequestPresentation(request) + } + hideSheet() + } + fun handleDeeplinkIntent(intent: Intent) { if (intent.action !in DEEPLINK_ACTIONS) return intent.data?.let { uri -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e3540d739..1af6e9845f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -997,6 +997,7 @@ The contact you\'re trying to send to hasn\'t enabled payments. No compatible payment endpoint is available. Unable To Pay Contact + Waiting for the contact to update their payment details. Please try again shortly. Own your\n<accent>profile</accent> Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Profile diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4763db2b0a..9237350f3c 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -29,7 +29,6 @@ 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.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations @@ -59,6 +58,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -182,6 +182,13 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `hasPrivatePaymentAccess returns false when the SDK check fails`() = test { + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenThrow(IllegalStateException("Paykit unavailable")) + + assertFalse(sut.hasPrivatePaymentAccess()) + } + @Test fun `prepareSavedContacts publishes distinct private reservations for eligible receiver paths`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 357294fb8b..42ea724330 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -35,6 +35,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -76,7 +77,6 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest -import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -115,6 +115,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -423,6 +424,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `unresolvable payment request retries are bounded`() = test { + val request = paymentRequest() + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.beginPaymentRequest(request) } + .thenReturn(Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList)) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(20.minutes.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, times(5)).beginPaymentRequest(request) + } + @Test fun `active contact payment prevents presenting another payment request`() = test { val activeRequest = paymentRequest() @@ -1879,7 +1899,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `incoming payment request is accepted before its private list is consumed`() = test { + fun `incoming payment request consumes its private list before it is accepted`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) @@ -1910,8 +1930,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo).accept(request) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + } + } + + @Test + fun `incoming payment request is not accepted when private list consumption fails`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + .thenReturn(Result.failure(IllegalStateException("Payment list already consumed"))) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) } @Test @@ -1997,8 +2054,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val address = "bcrt1qexpiredrequest" val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - whenever { paykitPaymentRequestRepo.accept(request) } - .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) + whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -2012,6 +2068,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), From d140a46153406efd798c3e458abff11967c00bb9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:28:20 +0200 Subject: [PATCH 6/8] refactor: batch paykit contact cleanup --- .../bitkit/repositories/PrivatePaykitRepo.kt | 151 ++++++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 83 ++++++++++ 2 files changed, 184 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index a5cc916858..9016016782 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -107,6 +107,12 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class PrivateEndpointCleanupPreparation( + val clearedRetryKeys: List, + val failedPublicKeys: Set, + val firstError: Throwable?, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -683,6 +689,7 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() + val linkedReceiverPaths = linkedReceiverPathsByPublicKey() for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -699,15 +706,12 @@ class PrivatePaykitRepo @Inject constructor( val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths receiverPathSelection.error?.let { firstError = firstError ?: it - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) + logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), ) (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> @@ -817,6 +821,18 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun logPrivateReceiverPathSelectionFailure( + publicKey: String, + reason: String, + error: Throwable, + ) { + Logger.warn( + "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, @@ -1170,41 +1186,77 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - val firstError = keys.mapNotNull { publicKey -> - removePublishedEndpoints(publicKey).exceptionOrNull() - }.firstOrNull() - if (firstError != null) throw firstError - } + val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) + .distinct() + removePublishedEndpoints(keys) } - private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { - runSuspendCatching { - var firstError: Throwable? = null - receiverPathsForCleanup(publicKey).forEach { receiverPath -> - val result = runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList( - counterparty = publicKey, - receiverPath = receiverPath, + private suspend fun removePublishedEndpoints(publicKey: String): Result = + removePublishedEndpoints(listOf(publicKey)) + + private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + val publicKeys = publicKeys.mapNotNull(::normalizedPublicKey).distinct() + if (publicKeys.isEmpty()) return@runSuspendCatching Unit + + val linkedReceiverPaths = linkedReceiverPathsByPublicKey() + val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPaths) + val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() + var firstError = preparation.firstError + + if (preparation.clearedRetryKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + advancingLinksFor = preparation.clearedRetryKeys, ) - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable + val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys.map { it.publicKey } + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } - val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) - drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) - if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { + } + + val successfulPublicKeys = publicKeys.filterNot { it in failedPublicKeys } + clearPublishedEndpointCache(successfulPublicKeys) + + firstError?.let { throw it } + Unit + } + } + + private suspend fun clearPrivatePaymentLists( + publicKeys: Collection, + linkedReceiverPaths: Map>, + ): PrivateEndpointCleanupPreparation { + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = null + + publicKeys.forEach { publicKey -> + receiverPathsForCleanup( + publicKey = publicKey, + linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + ).forEach { receiverPath -> + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } - } - if (result.isFailure) { - firstError = firstError ?: result.exceptionOrNull() + }.onSuccess { + clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } + } - firstError?.let { throw it } + return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) + } + private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() contactState.localInvoicesByReceiverPath = emptyMap() @@ -1214,6 +1266,9 @@ class PrivatePaykitRepo @Inject constructor( } } updateDeletedContactCleanupPending(publicKey, isPending = false) + } + + if (publicKeys.isNotEmpty()) { persistState(markWalletBackup = true) } } @@ -1226,42 +1281,38 @@ class PrivatePaykitRepo @Inject constructor( return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } } - private suspend fun receiverPathsForPrivateEndpointCleanup( + private fun receiverPathsForPrivateEndpointCleanup( publicKey: String, excludedReceiverPaths: List, + linkedReceiverPaths: Collection, ): List { val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - val linkedPaths = linkedReceiverPaths(publicKey) - return (publishedPaths + linkedPaths) + return (publishedPaths + linkedReceiverPaths) .filter { it in PaykitReceiverPaths.supported } .filterNot { it in excludedReceiverPaths } .distinct() .sorted() } - private suspend fun receiverPathsForCleanup(publicKey: String): List { - val paths = ( - linkedReceiverPaths(publicKey) + - publishedPrivatePaymentReceiverPaths(publicKey) - ) + private fun receiverPathsForCleanup( + publicKey: String, + linkedReceiverPaths: Collection, + ): List { + return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) .filter { it in PaykitReceiverPaths.supported } .distinct() .sorted() - return paths } - private suspend fun linkedReceiverPaths(publicKey: String): List { - val normalizedKey = normalizedPublicKey(publicKey) ?: return emptyList() - val paths = paykitSdkService.linkedPeers() - .mapNotNull { peer -> - val peerKey = normalizedPublicKey(peer.counterparty) - peer.counterpartyReceiverPath.takeIf { - peerKey == normalizedKey && it in PaykitReceiverPaths.supported - } + private suspend fun linkedReceiverPathsByPublicKey(): Map> { + val linkedPaths = mutableMapOf>() + paykitSdkService.linkedPeers().forEach { peer -> + val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach + if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { + linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath } - .distinct() - .sorted() - return paths + } + return linkedPaths } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 9237350f3c..e622e3f7a6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -44,6 +44,7 @@ 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.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState @@ -388,6 +389,26 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( + privateReceiverPathSelection( + publishableReceiverPaths = emptyList(), + linkableReceiverPaths = emptyList(), + ), + ) + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } + } + @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( @@ -635,6 +656,64 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } } + @Test + fun `cleanup drains all contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() }.thenReturn( + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } + verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } + verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + } + + @Test + fun `cleanup isolates a failed contact while clearing successful contacts`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + privateListDeliveryReport( + failedToQueue = listOf( + PrivatePaymentListSyncChange( + counterparty = CONTACT_KEY, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, + outboundMessageId = null, + error = "failed", + ), + ), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `prepareSavedContacts records queued contacts when another contact cannot publish`() = test { settingsData.value = SettingsData( @@ -1117,6 +1196,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) + private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( + publishedPrivatePaymentReceiverPaths = setOf(receiverPath), + ) + private fun privateReceiverPathSelection( publishableReceiverPaths: List, linkableReceiverPaths: List = publishableReceiverPaths, From bad841b54b6265a448115112ec93dac7d692e245 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:52:21 +0200 Subject: [PATCH 7/8] fix: preserve paykit cleanup retries --- .../bitkit/repositories/PrivatePaykitRepo.kt | 126 +++++++++++++++--- .../repositories/PrivatePaykitRepoTest.kt | 45 +++++++ 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 9016016782..3aef03f298 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -113,6 +113,16 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class NormalizedPublicKeyBatch( + val publicKeys: List, + val hadInvalidKey: Boolean, + ) + + private data class LinkedReceiverPathInspection( + val receiverPaths: Set, + val error: Throwable?, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -689,7 +699,7 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() - val linkedReceiverPaths = linkedReceiverPathsByPublicKey() + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull(reason) for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -708,22 +718,19 @@ class PrivatePaykitRepo @Inject constructor( firstError = firstError ?: it logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } + val linkedReceiverPathInspection = inspectLinkedReceiverPaths( + publicKey, + linkedReceiverPathsSnapshot, + reason, + ) + firstError = firstError ?: linkedReceiverPathInspection.error val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + linkedReceiverPaths = linkedReceiverPathInspection.receiverPaths, ) - (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> - linkRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - } + linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) cleanupReceiverPaths.forEach { receiverPath -> updates += PrivatePaymentListReservationUpdateInput( @@ -775,6 +782,21 @@ class PrivatePaykitRepo @Inject constructor( drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } + private suspend fun preparePrivateLinks( + publicKey: String, + receiverPaths: Collection, + reason: String, + ): List = receiverPaths.distinct().map { receiverPath -> + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + } + PrivateMessageDrainRetryKey(publicKey, receiverPath) + } + private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, @@ -1197,13 +1219,20 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { - val publicKeys = publicKeys.mapNotNull(::normalizedPublicKey).distinct() - if (publicKeys.isEmpty()) return@runSuspendCatching Unit + val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + val publicKeys = normalizedBatch.publicKeys + var firstError: Throwable? = PrivatePaykitError.InvalidPublicKey.takeIf { + normalizedBatch.hadInvalidKey + } + if (publicKeys.isEmpty()) { + firstError?.let { throw it } + return@runSuspendCatching Unit + } - val linkedReceiverPaths = linkedReceiverPathsByPublicKey() - val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPaths) + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull("private endpoint cleanup") + val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPathsSnapshot) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() - var firstError = preparation.firstError + firstError = firstError ?: preparation.firstError if (preparation.clearedRetryKeys.isNotEmpty()) { drainPendingPrivateMessages( @@ -1227,16 +1256,28 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPaths: Map>, + linkedReceiverPathsSnapshot: Map>?, ): PrivateEndpointCleanupPreparation { val failedPublicKeys = mutableSetOf() val clearedRetryKeys = mutableListOf() var firstError: Throwable? = null publicKeys.forEach { publicKey -> + val contactLinkedReceiverPaths = linkedReceiverPaths( + publicKey = publicKey, + snapshot = linkedReceiverPathsSnapshot, + ).onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during cleanup", + it, + context = TAG, + ) + }.getOrDefault(emptySet()) receiverPathsForCleanup( publicKey = publicKey, - linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + linkedReceiverPaths = contactLinkedReceiverPaths, ).forEach { receiverPath -> runSuspendCatching { val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) @@ -1315,6 +1356,53 @@ class PrivatePaykitRepo @Inject constructor( return linkedPaths } + private suspend fun linkedReceiverPathsSnapshotOrNull(reason: String): Map>? = + runSuspendCatching { linkedReceiverPathsByPublicKey() } + .onFailure { + Logger.warn( + "Failed to inspect private Paykit links during '$reason'; retrying per contact", + it, + context = TAG, + ) + }.getOrNull() + + private suspend fun linkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + ): Result> = if (snapshot != null) { + Result.success(snapshot[publicKey].orEmpty()) + } else { + runSuspendCatching { linkedReceiverPathsByPublicKey()[publicKey].orEmpty() } + } + + private suspend fun inspectLinkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + reason: String, + ): LinkedReceiverPathInspection { + val result = linkedReceiverPaths(publicKey, snapshot) + val error = result.exceptionOrNull() + if (error != null) { + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + return LinkedReceiverPathInspection(result.getOrDefault(emptySet()), error) + } + + private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { + var hadInvalidKey = false + val normalizedKeys = publicKeys.mapNotNull { publicKey -> + normalizedPublicKey(publicKey) ?: run { + hadInvalidKey = true + null + } + }.distinct() + return NormalizedPublicKeyBatch(normalizedKeys, hadInvalidKey) + } + private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { val contactState = state?.contacts?.get(publicKey) ?: return emptyList() return contactState.publishedPrivatePaymentReceiverPaths.toList() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index e622e3f7a6..15a80240f1 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -634,6 +634,51 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `cleanup falls back per contact when shared linked receiver inspection fails`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + var linkedPeerReads = 0 + whenever { paykitSdkService.linkedPeers() }.thenAnswer { + linkedPeerReads += 1 + if (linkedPeerReads <= 2) error("link inspection failed") + emptyList() + } + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `invalid deleted contact key remains pending for cleanup`() = test { + val invalidPublicKey = "not-a-pubky" + cacheData.value = PrivatePaykitCacheData( + deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isFailure) + assertEquals(setOf(invalidPublicKey), cacheData.value.deletedContactCleanupPendingPublicKeys) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } } From 670e9e761808fc9e05b9b22b43aed0c4183bc57c Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 3 Aug 2026 09:23:09 +0200 Subject: [PATCH 8/8] fix: harden paykit cleanup batching --- .../bitkit/repositories/PrivatePaykitRepo.kt | 261 ++++++++++-------- .../repositories/PrivatePaykitRepoTest.kt | 94 ++++++- 2 files changed, 235 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 3aef03f298..08ee7f1136 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -114,15 +114,21 @@ class PrivatePaykitRepo @Inject constructor( ) private data class NormalizedPublicKeyBatch( - val publicKeys: List, - val hadInvalidKey: Boolean, + val normalizedKeys: List, + val invalidKeys: Set, ) - private data class LinkedReceiverPathInspection( - val receiverPaths: Set, + private data class LinkedReceiverPathsSnapshot( + val pathsByPublicKey: Map>, val error: Throwable?, ) + private data class PublishedEndpointCleanupState( + val remoteEndpoints: List, + val localInvoicesByReceiverPath: Map, + val publishedPrivatePaymentReceiverPaths: Set, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -699,7 +705,8 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull(reason) + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) + firstError = linkedReceiverPathsSnapshot.error for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -718,16 +725,10 @@ class PrivatePaykitRepo @Inject constructor( firstError = firstError ?: it logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } - val linkedReceiverPathInspection = inspectLinkedReceiverPaths( - publicKey, - linkedReceiverPathsSnapshot, - reason, - ) - firstError = firstError ?: linkedReceiverPathInspection.error val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPathInspection.receiverPaths, + linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), ) linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) @@ -786,15 +787,20 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, receiverPaths: Collection, reason: String, - ): List = receiverPaths.distinct().map { receiverPath -> - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) + ): List { + val retryKeys = mutableListOf() + for (receiverPath in receiverPaths.distinct()) { + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + } + retryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) } - PrivateMessageDrainRetryKey(publicKey, receiverPath) + + return retryKeys } private suspend fun drainAndSchedulePrivateLinkRetries( @@ -1208,9 +1214,11 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - removePublishedEndpoints(keys) + publicationMutex.withLock { + val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) + .distinct() + removePublishedEndpointsLocked(keys) + } } private suspend fun removePublishedEndpoints(publicKey: String): Result = @@ -1218,66 +1226,68 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val normalizedBatch = normalizedPublicKeyBatch(publicKeys) - val publicKeys = normalizedBatch.publicKeys - var firstError: Throwable? = PrivatePaykitError.InvalidPublicKey.takeIf { - normalizedBatch.hadInvalidKey - } - if (publicKeys.isEmpty()) { - firstError?.let { throw it } - return@runSuspendCatching Unit - } + publicationMutex.withLock { + removePublishedEndpointsLocked(publicKeys) + } + } - val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull("private endpoint cleanup") - val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPathsSnapshot) - val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() - firstError = firstError ?: preparation.firstError + private suspend fun removePublishedEndpointsLocked(publicKeys: Collection): Result = + runSuspendCatching { + val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + discardInvalidCleanupKeys(normalizedBatch.invalidKeys) + val normalizedKeys = normalizedBatch.normalizedKeys + if (normalizedKeys.isEmpty()) return@runSuspendCatching + + ensureState() + val cleanupStateByPublicKey = normalizedKeys.associateWith(::publishedEndpointCleanupState) + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot("private endpoint cleanup") + val preparation = clearPrivatePaymentLists(normalizedKeys, linkedReceiverPathsSnapshot) + val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() + var firstError = preparation.firstError + + if (preparation.clearedRetryKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + advancingLinksFor = preparation.clearedRetryKeys, + ) + val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys.map { it.publicKey } + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + } + } - if (preparation.clearedRetryKeys.isNotEmpty()) { - drainPendingPrivateMessages( - reason = "private endpoint cleanup", - advancingLinksFor = preparation.clearedRetryKeys, + normalizedKeys.filterNot { it in failedPublicKeys }.forEach { publicKey -> + if (publishedEndpointCleanupState(publicKey) != cleanupStateByPublicKey[publicKey]) { + failedPublicKeys += publicKey + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable + Logger.warn( + "Deferred private Paykit cache cleanup for '${redacted(publicKey)}' because its state changed", + context = TAG, ) - val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) - if (pendingRetryKeys.isNotEmpty()) { - failedPublicKeys += pendingRetryKeys.map { it.publicKey } - firstError = firstError ?: PrivatePaykitError.PrivateUnavailable - } } - - val successfulPublicKeys = publicKeys.filterNot { it in failedPublicKeys } - clearPublishedEndpointCache(successfulPublicKeys) - - firstError?.let { throw it } - Unit } + + clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) + firstError?.let { throw it } } private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPathsSnapshot: Map>?, + linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, ): PrivateEndpointCleanupPreparation { - val failedPublicKeys = mutableSetOf() + val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { + mutableSetOf() + } else { + publicKeys.toMutableSet() + } val clearedRetryKeys = mutableListOf() - var firstError: Throwable? = null + var firstError = linkedReceiverPathsSnapshot.error publicKeys.forEach { publicKey -> - val contactLinkedReceiverPaths = linkedReceiverPaths( - publicKey = publicKey, - snapshot = linkedReceiverPathsSnapshot, - ).onFailure { - failedPublicKeys += publicKey - firstError = firstError ?: it - Logger.warn( - "Failed to inspect private Paykit links for '${redacted(publicKey)}' during cleanup", - it, - context = TAG, - ) - }.getOrDefault(emptySet()) receiverPathsForCleanup( publicKey = publicKey, - linkedReceiverPaths = contactLinkedReceiverPaths, + linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), ).forEach { receiverPath -> runSuspendCatching { val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) @@ -1297,6 +1307,8 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() @@ -1306,12 +1318,34 @@ class PrivatePaykitRepo @Inject constructor( state?.contacts?.remove(publicKey) } } - updateDeletedContactCleanupPending(publicKey, isPending = false) } - if (publicKeys.isNotEmpty()) { + persistState(markWalletBackup = true) + updateDeletedContactCleanupPending(publicKeys, isPending = false) + } + + private suspend fun discardInvalidCleanupKeys(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + + val contactState = ensureState().contacts + var didRemoveContactState = false + publicKeys.forEach { publicKey -> + Logger.warn("Dropped invalid private Paykit cleanup key '${redacted(publicKey)}'", context = TAG) + didRemoveContactState = contactState.remove(publicKey) != null || didRemoveContactState + } + if (didRemoveContactState) { persistState(markWalletBackup = true) } + updateDeletedContactCleanupPending(publicKeys, isPending = false) + } + + private fun publishedEndpointCleanupState(publicKey: String): PublishedEndpointCleanupState { + val contactState = state?.contacts?.get(publicKey) + return PublishedEndpointCleanupState( + remoteEndpoints = contactState?.remoteEndpoints.orEmpty(), + localInvoicesByReceiverPath = contactState?.localInvoicesByReceiverPath.orEmpty(), + publishedPrivatePaymentReceiverPaths = contactState?.publishedPrivatePaymentReceiverPaths.orEmpty(), + ) } private suspend fun receiverPathsForSavedContact(publicKey: String): List { @@ -1356,51 +1390,32 @@ class PrivatePaykitRepo @Inject constructor( return linkedPaths } - private suspend fun linkedReceiverPathsSnapshotOrNull(reason: String): Map>? = - runSuspendCatching { linkedReceiverPathsByPublicKey() } - .onFailure { - Logger.warn( - "Failed to inspect private Paykit links during '$reason'; retrying per contact", - it, - context = TAG, - ) - }.getOrNull() - - private suspend fun linkedReceiverPaths( - publicKey: String, - snapshot: Map>?, - ): Result> = if (snapshot != null) { - Result.success(snapshot[publicKey].orEmpty()) - } else { - runSuspendCatching { linkedReceiverPathsByPublicKey()[publicKey].orEmpty() } - } - - private suspend fun inspectLinkedReceiverPaths( - publicKey: String, - snapshot: Map>?, - reason: String, - ): LinkedReceiverPathInspection { - val result = linkedReceiverPaths(publicKey, snapshot) - val error = result.exceptionOrNull() - if (error != null) { + private suspend fun linkedReceiverPathsSnapshot(reason: String): LinkedReceiverPathsSnapshot { + repeat(2) { attempt -> + val result = runSuspendCatching { linkedReceiverPathsByPublicKey() } + result.getOrNull()?.let { return LinkedReceiverPathsSnapshot(it, null) } + val error = result.exceptionOrNull() ?: PrivatePaykitError.PrivateUnavailable + val suffix = if (attempt == 0) "; retrying once" else " after retry" Logger.warn( - "Failed to inspect private Paykit links for '${redacted(publicKey)}' during '$reason'", + "Failed to inspect private Paykit links during '$reason'$suffix", error, context = TAG, ) + if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) } - return LinkedReceiverPathInspection(result.getOrDefault(emptySet()), error) + + return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) } private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { - var hadInvalidKey = false + val invalidKeys = mutableSetOf() val normalizedKeys = publicKeys.mapNotNull { publicKey -> normalizedPublicKey(publicKey) ?: run { - hadInvalidKey = true + invalidKeys += publicKey null } }.distinct() - return NormalizedPublicKeyBatch(normalizedKeys, hadInvalidKey) + return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { @@ -1421,7 +1436,14 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun clearContactState(publicKey: String) { - ensureState().contacts.remove(publicKey) + clearContactStates(listOf(publicKey)) + } + + private suspend fun clearContactStates(publicKeys: Collection) { + if (publicKeys.isEmpty()) return + + val contacts = ensureState().contacts + publicKeys.forEach(contacts::remove) persistState(markWalletBackup = true) } @@ -1514,12 +1536,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun pendingDeletedContactCleanupPublicKeys(): Set = cacheStore.data.first().deletedContactCleanupPendingPublicKeys - private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) { + private suspend fun updateDeletedContactCleanupPending(publicKey: String, isPending: Boolean) = + updateDeletedContactCleanupPending(listOf(publicKey), isPending) + + private suspend fun updateDeletedContactCleanupPending(publicKeys: Collection, isPending: Boolean) { + if (publicKeys.isEmpty()) return + cacheStore.update { val pendingKeys = if (isPending) { - it.deletedContactCleanupPendingPublicKeys + publicKey + it.deletedContactCleanupPendingPublicKeys + publicKeys } else { - it.deletedContactCleanupPendingPublicKeys - publicKey + it.deletedContactCleanupPendingPublicKeys - publicKeys.toSet() } it.copy(deletedContactCleanupPendingPublicKeys = pendingKeys) } @@ -1530,17 +1557,21 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { val savedKeys = savedPublicKeys.mapNotNull { normalizedPublicKey(it) }.toSet() - pendingDeletedContactCleanupPublicKeys().forEach { publicKey -> - if (publicKey in savedKeys) { - updateDeletedContactCleanupPending(publicKey, false) - return@forEach - } - - removePublishedEndpoints(publicKey).getOrThrow() - clearContactState(publicKey) + val pendingKeys = pendingDeletedContactCleanupPublicKeys() + updateDeletedContactCleanupPending(pendingKeys.intersect(savedKeys), isPending = false) + val cleanupKeys = pendingKeys - savedKeys + if (cleanupKeys.isEmpty()) return@runSuspendCatching + + val removalResult = removePublishedEndpoints(cleanupKeys) + val remainingPendingKeys = pendingDeletedContactCleanupPublicKeys() + val successfulKeys = cleanupKeys + .mapNotNull(::normalizedPublicKey) + .filterNot { it in remainingPendingKeys } + clearContactStates(successfulKeys) + successfulKeys.forEach { publicKey -> addressReservationRepo.clearContactAssignment(publicKey) - updateDeletedContactCleanupPending(publicKey, false) } + removalResult.getOrThrow() } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 15a80240f1..0cbc0df758 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -16,7 +16,9 @@ import com.synonym.paykit.PrivatePaymentResolutionState import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -33,6 +35,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -642,7 +645,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `cleanup falls back per contact when shared linked receiver inspection fails`() = test { + fun `cleanup retries linked receiver inspection once for the batch`() = test { cacheData.value = PrivatePaykitCacheData( contacts = mapOf( CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), @@ -663,22 +666,25 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } assertTrue(CONTACT_KEY in cacheData.value.contacts) - assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) assertTrue(cacheData.value.cleanupPending) + assertEquals(3, linkedPeerReads) } @Test - fun `invalid deleted contact key remains pending for cleanup`() = test { + fun `invalid deleted contact key is dropped from cleanup state`() = test { val invalidPublicKey = "not-a-pubky" cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(invalidPublicKey to cachedPublishedContact(WALLET_RECEIVER_PATH)), deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), ) sut = createSut() val result = sut.retryPendingEndpointRemoval(emptyList()) - assertTrue(result.isFailure) - assertEquals(setOf(invalidPublicKey), cacheData.value.deletedContactCleanupPendingPublicKeys) + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } } @@ -729,6 +735,84 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(cacheData.value.contacts.isEmpty()) } + @Test + fun `deleted contact retry cleans all pending contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + deletedContactCleanupPendingPublicKeys = setOf(CONTACT_KEY, OTHER_CONTACT_KEY), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) + } + + @Test + fun `cleanup retains the batch when drain inspection fails`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() } + .thenReturn(emptyList()) + .thenThrow(IllegalStateException("drain inspection failed")) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `cleanup retains endpoint cache updated during remote removal`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf(CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH)), + ) + sut = createSut() + val cleanupStarted = CompletableDeferred() + val resumeCleanup = CompletableDeferred() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + .doSuspendableAnswer { + cleanupStarted.complete(Unit) + resumeCleanup.await() + privateListDeliveryReport(clearedCounterparties = listOf(CONTACT_KEY)) + } + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.thenReturn(resolution(resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), version = 7uL)) + whenever(coreService.isAddressUsed(PRIVATE_ADDRESS)).thenReturn(false) + + val cleanup = async { sut.removePublishedEndpointsForCleanup("test") } + cleanupStarted.await() + sut.beginPaymentRequest( + paymentRequest(acceptedEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue)), + ).getOrThrow() + resumeCleanup.complete(Unit) + + assertTrue(cleanup.await().isFailure) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).remoteEndpoints.isNotEmpty()) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `cleanup isolates a failed contact while clearing successful contacts`() = test { cacheData.value = PrivatePaykitCacheData(