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..999a72349b --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -0,0 +1,247 @@ +@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.Job +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.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 +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 expiresAt: Instant?, + val acceptedPaymentEndpointIdentifiers: List, +) { + val id: PaykitPaymentRequestId + get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) + + fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true + + fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = + amountMsats == null || amountMsats == satsToMsat(amountSats) + + fun acceptsLightningInvoiceAmountSats(amountSats: ULong): Boolean = + amountSats == 0uL || acceptsPaymentAmount(amountSats) + + fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats +} + +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 = appScope(ioDispatcher, TAG) + 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) + } + + fun isPending(request: PaykitPaymentRequest): Boolean = + !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } + + 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() + ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } + ?: 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, + expiresAt = expiresAt, + acceptedPaymentEndpointIdentifiers = endpoints, + ) +} + +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..08ee7f1136 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 @@ -70,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 @@ -86,6 +85,7 @@ class PrivatePaykitRepo @Inject constructor( 45.seconds, 90.seconds, ) + private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -107,6 +107,28 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class PrivateEndpointCleanupPreparation( + val clearedRetryKeys: List, + val failedPublicKeys: Set, + val firstError: Throwable?, + ) + + private data class NormalizedPublicKeyBatch( + val normalizedKeys: List, + val invalidKeys: 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, @@ -297,61 +319,59 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + beginSavedContactPaymentWithRetry(normalizedKey) + } + } - 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 discardRemoteLightningEndpoints( + suspend fun consumePrivatePaymentList( publicKey: String, - paymentHashes: Set, - paymentRequests: Set = emptySet(), + context: PrivatePaykitPaymentContext, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (paymentHashes.isEmpty() && paymentRequests.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) + 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 } - if (filteredEntries.size == contactState.remoteEndpoints.size) return@runSuspendCatching - persistConsumedRemotePaymentList( - publicKey = normalizedKey, - contactState = contactState, - receiverPath = PaykitReceiverPaths.WALLET, - ).getOrThrow() + 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 discardRemoteOnchainEndpoints( + suspend fun discardRemoteLightningEndpoints( publicKey: String, - addresses: Set, + paymentHashes: Set, ): Result = withContext(serializedDispatcher) { runSuspendCatching { - if (addresses.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 { - shouldDiscardRemoteOnchainEntry(it, addresses) + 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) } } @@ -430,15 +450,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) } } @@ -446,95 +468,184 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { clearPendingMessageDrainRetries() + state = PrivatePaykitState() knownSavedContactKeys.clear() if (backup == null) { - 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, - ) - } + 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, + ) ?: 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)) { + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } - val resolution = paykitSdkService.prepareAndResolveContactPayment( - counterparty = publicKey, - receiverPath = PaykitReceiverPaths.WALLET, - includePublicEndpoints = true, - afterPrivatePaymentListVersion = consumedPaymentListVersion(publicKey), - ) - val privateEndpoints = resolution.payableEndpoints - .filter { it.source == PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST } - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - - cacheResolvedPrivateEndpoints( + val result = 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), - ) + if (paymentRequest?.isExpired(clock.now()) == true) { + throw PaykitPaymentRequestError.RequestExpired } + result + } + } - if (resolution.privateState == PrivatePaymentResolutionState.RECOVERY_PENDING) { - schedulePendingPrivateMessageDrainRetries( - reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, PaykitReceiverPaths.WALLET)), - ) - } + 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 + } - 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 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, + ) + } + } - resolution.publicResolutionError?.let { throw it } + 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 + } - if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { - PublicPaykitPaymentResult.NoEndpoint - } else { - PublicPaykitPaymentResult.NotOpened - } - } + 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) { + Logger.info("Opened private Paykit payment for '${redacted(publicKey)}'", context = TAG) + return PublicPaykitPaymentResult.Opened( + paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), + privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + ) + } + + 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 + } + + 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, @@ -594,6 +705,8 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshot(reason) + firstError = linkedReceiverPathsSnapshot.error for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -610,27 +723,15 @@ 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 = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), ) - (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( @@ -682,6 +783,26 @@ class PrivatePaykitRepo @Inject constructor( drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } + private suspend fun preparePrivateLinks( + publicKey: String, + receiverPaths: Collection, + reason: String, + ): 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) + } + + return retryKeys + } + private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, @@ -728,6 +849,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, @@ -1074,87 +1207,109 @@ 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 { + publicationMutex.withLock { val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) .distinct() - val firstError = keys.mapNotNull { publicKey -> - removePublishedEndpoints(publicKey).exceptionOrNull() - }.firstOrNull() - if (firstError != null) throw firstError + removePublishedEndpointsLocked(keys) } } - private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { + private suspend fun removePublishedEndpoints(publicKey: String): Result = + removePublishedEndpoints(listOf(publicKey)) + + private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + publicationMutex.withLock { + removePublishedEndpointsLocked(publicKeys) + } + } + + private suspend fun removePublishedEndpointsLocked(publicKeys: Collection): Result = runSuspendCatching { - var firstError: Throwable? = null - receiverPathsForCleanup(publicKey).forEach { receiverPath -> - val result = runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList( - counterparty = publicKey, - receiverPath = receiverPath, + 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 + } + } + + 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, ) + } + } + + clearPublishedEndpointCache(normalizedKeys.filterNot { it in failedPublicKeys }) + firstError?.let { throw it } + } + + private suspend fun clearPrivatePaymentLists( + publicKeys: Collection, + linkedReceiverPathsSnapshot: LinkedReceiverPathsSnapshot, + ): PrivateEndpointCleanupPreparation { + val failedPublicKeys = if (linkedReceiverPathsSnapshot.error == null) { + mutableSetOf() + } else { + publicKeys.toMutableSet() + } + val clearedRetryKeys = mutableListOf() + var firstError = linkedReceiverPathsSnapshot.error + + publicKeys.forEach { publicKey -> + receiverPathsForCleanup( + publicKey = publicKey, + linkedReceiverPaths = linkedReceiverPathsSnapshot.pathsByPublicKey[publicKey].orEmpty(), + ).forEach { receiverPath -> + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } - val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) - drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) - if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { - 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) { + if (publicKeys.isEmpty()) return + + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() contactState.localInvoicesByReceiverPath = emptyMap() @@ -1163,9 +1318,34 @@ class PrivatePaykitRepo @Inject constructor( state?.contacts?.remove(publicKey) } } - updateDeletedContactCleanupPending(publicKey, isPending = false) + } + + 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 { @@ -1176,42 +1356,66 @@ 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 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 during '$reason'$suffix", + error, + context = TAG, + ) + if (attempt == 1) return LinkedReceiverPathsSnapshot(emptyMap(), error) + } + + return LinkedReceiverPathsSnapshot(emptyMap(), PrivatePaykitError.PrivateUnavailable) + } + + private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { + val invalidKeys = mutableSetOf() + val normalizedKeys = publicKeys.mapNotNull { publicKey -> + normalizedPublicKey(publicKey) ?: run { + invalidKeys += publicKey + null + } + }.distinct() + return NormalizedPublicKeyBatch(normalizedKeys, invalidKeys) } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { @@ -1232,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) } @@ -1279,7 +1490,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)}'", @@ -1291,51 +1502,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 - } - } - - 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 + val paymentHash = paymentHashForBolt11(endpoint.value)?.lowercase() ?: return false + return paymentHash in paymentHashes } private suspend fun canPublishPrivateEndpoints(): Boolean { @@ -1362,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) } @@ -1378,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() } } @@ -1503,9 +1686,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/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..add30a715e 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 @@ -70,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 @@ -77,21 +84,23 @@ 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, - val privatePaymentListVersion: ULong? = null, - val publicResolutionError: Throwable? = null, ) -enum class PaykitPaymentEndpointSource { - PRIVATE_PAYMENT_LIST, - PUBLIC_PAYMENT_ENDPOINT, -} +data class PaykitPublicContactPaymentResolution( + val payableEndpoints: List, +) data class PaykitResolvedPaymentEndpoint( - val counterparty: String, - val source: PaykitPaymentEndpointSource, val identifier: String, val payload: String, ) @@ -112,7 +121,7 @@ internal object PaykitReceiverPaths { } @Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, @@ -142,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) { @@ -460,14 +471,7 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - handle.publishPaykitReceiverMarker( - PaykitReceiverCapabilities( - privatePayments = sessionProvider.hasSessionAccess(), - paymentRequests = false, - receipts = false, - outgoingPayments = true, - ), - ) + handle.publishPaykitReceiverMarker(receiverCapabilities()) } } } @@ -523,24 +527,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,82 +579,63 @@ 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 { + val prepared = operationMutex.withLock { withStateRevisionTracking { handle -> - val privateResolution = handle.prepareAndResolvePrivateContactPayment( + handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, counterpartyReceiverPath = receiverPath, - amount = null, + amount = amount, afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, - ).resolution - val publicResolution = if (includePublicEndpoints) { - runSuspendCatching { - handle.resolvePublicContactPayment(counterparty, receiverPath, amount = null) - } - } else { - null - } - 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( - counterparty = it.counterparty, - source = PaykitPaymentEndpointSource.PRIVATE_PAYMENT_LIST, 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(), + private fun PublicContactPaymentResolution.toPaykitPublicContactPaymentResolution() = + PaykitPublicContactPaymentResolution( + payableEndpoints = payableEndpoints.map { + PaykitResolvedPaymentEndpoint( + identifier = it.identifier, + payload = it.target.payload.exportText(), + ) + }, ) - } suspend fun exportBackupState(): String { isSetup.await() @@ -728,7 +733,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() + if (capabilities.privatePayments) { + handle.publishPaykitReceiverMarker(capabilities) + } + }.onFailure { + Logger.warn("Failed to publish Paykit receiver marker", it, context = TAG) + } + } + + private fun receiverCapabilities(): PaykitReceiverCapabilities { + val hasPrivatePaymentAccess = sessionProvider.hasSessionAccess() + return PaykitReceiverCapabilities( + privatePayments = hasPrivatePaymentAccess, + paymentRequests = hasPrivatePaymentAccess, + receipts = false, + outgoingPayments = true, + ) } private fun notifyBackupStateChanged() { @@ -783,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/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..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 @@ -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_waiting_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..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 @@ -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_waiting_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..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 @@ -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_waiting_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..53112d6ec1 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 @@ -58,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 @@ -132,13 +135,19 @@ 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.PaykitPaymentRequestError +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 +219,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 +288,12 @@ 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 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) @@ -399,6 +415,8 @@ class AppViewModel @Inject constructor( observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() + observePaykitPaymentRequestConnectivity() + observeIncomingPaykitPaymentRequests() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -534,6 +552,7 @@ class AppViewModel @Inject constructor( .collect { state -> if (!state.isPaykitEnabled || state.publicKey == null) { lastPrivatePaykitContactKeys = emptySet() + paykitPaymentRequestRepo.clear() return@collect } @@ -560,6 +579,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + refreshIncomingPaykitPaymentRequests() lastPrivatePaykitContactKeys = state.contactKeys } } @@ -580,6 +600,140 @@ 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(): 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_INTERVALS[refreshIntervalIndex]) + refreshIntervalIndex = if (refreshIncomingPaykitPaymentRequests()) { + 0 + } else { + (refreshIntervalIndex + 1).coerceAtMost(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS.lastIndex) + } + } + } + } + + fun stopPaykitPaymentRequestPolling() { + paykitPaymentRequestPollingJob?.cancel() + paykitPaymentRequestPollingJob = null + paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } + paymentRequestPresentationRetryJobs.clear() + paymentRequestPresentationRetryAttempts.clear() + } + + private fun observeIncomingPaykitPaymentRequests() { + viewModelScope.launch { + 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 presentNextIncomingPaykitPaymentRequest() { + val requests = paykitPaymentRequestRepo.pendingRequests.value + retainPaymentRequestPresentationState(requests) + if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return + isPresentingPaymentRequest = true + try { + 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 (presentIncomingPaymentRequestOrStop(request)) return + } + } finally { + isPresentingPaymentRequest = false + } + } + + private suspend fun presentIncomingPaymentRequestOrStop(request: PaykitPaymentRequest): Boolean { + val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() + if (currentSheet.value != null || hasActiveContactPaymentContext()) return true + val isPending = paykitPaymentRequestRepo.isPending(request) + if (result !is PublicPaykitPaymentResult.Opened || !isPending) { + if (isPending) 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 + paymentRequestPresentationRetryAttempts[request.id] = attempt + 1 + val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.getOrNull(attempt) ?: run { + Logger.warn("Giving up payment request presentation after '${attempt + 1}' attempts", context = TAG) + return + } + 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) { @@ -1678,9 +1832,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 +1864,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 +2031,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? { @@ -1910,8 +2074,13 @@ 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 lnInvoice = extractViableLightningInvoice(invoice.params)?.takeIf { + incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(it.amountSatoshis) != false + } + val amount = incomingPaymentRequest?.amountSats + ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } + ?: invoice.amountSatoshis _sendUiState.update { it.copy( address = invoice.address, @@ -1925,6 +2094,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 +2214,25 @@ class AppViewModel @Inject constructor( return } + val incomingPaymentRequest = activeIncomingPaymentRequest() + if (incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis) == false) { + rejectMismatchedPaymentRequest() + return + } + + val amount = incomingPaymentRequest?.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 +2246,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( - amount = invoice.amountSatoshis, + amount = amount, addressInput = scanResult, isAddressInputValid = true, decodedInvoice = invoice, @@ -2033,7 +2254,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 +2270,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 +2294,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 +2304,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, ) @@ -2352,9 +2584,21 @@ 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 + if (!validateIncomingPaymentRequest(contactPaymentContext)) return + + consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { + handlePaymentPreparationFailure(contactPaymentContext, it) + return + } + + acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + handlePaymentPreparationFailure(contactPaymentContext, it) + return + } + val amount = _sendUiState.value.amount val lnurl = _sendUiState.value.lnurl @@ -2382,13 +2626,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( @@ -2423,8 +2661,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() @@ -2442,41 +2678,81 @@ 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() + } } } } + 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 + + val lightningInvoice = _sendUiState.value.decodedInvoice ?: return false + return !incomingPaymentRequest.acceptsLightningInvoice(lightningInvoice) + } + + 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 validateIncomingPaymentRequest( + contactPaymentContext: ContactPaymentContext?, + ): Boolean { + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + if ( + (_sendUiState.value.isPaymentRequest && incomingPaymentRequest == null) || + hasMismatchedIncomingPaymentRequest(contactPaymentContext) + ) { + rejectMismatchedPaymentRequest() + return false + } + if (incomingPaymentRequest != null && !paykitPaymentRequestRepo.isPending(incomingPaymentRequest)) { + toast(PaykitPaymentRequestError.RequestUnavailable) + hideSheet() + return false + } + return true + } + + 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) @@ -2777,7 +3053,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 +3070,7 @@ class AppViewModel @Inject constructor( speed = speed, feeRates = rates, contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, ) } } @@ -3113,9 +3393,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 + } } } @@ -3153,37 +3446,25 @@ 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(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 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(context: ContactPaymentContext?): Result { + val request = context?.incomingPaymentRequest ?: return Result.success(Unit) + 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) { @@ -3361,6 +3642,7 @@ class AppViewModel @Inject constructor( fun onHomeResumed() { checkTimedSheets() hwWalletRepo.onAppForegrounded() + viewModelScope.launch { refreshIncomingPaykitPaymentRequests() } } fun onLeftHome() = timedSheetManager.onHomeScreenExited() @@ -3416,6 +3698,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_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" @@ -3456,6 +3745,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 +3765,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..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 @@ -1148,6 +1149,8 @@ MINIMUM 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 new file mode 100644 index 0000000000..47d388b48a --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -0,0 +1,238 @@ +@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.assertFalse +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(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, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + ) + + 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( + 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) + } + } + + @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, + 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..0cbc0df758 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -8,13 +8,17 @@ 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.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -27,14 +31,15 @@ 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 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 +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -42,12 +47,13 @@ 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 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 @@ -56,12 +62,14 @@ 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 import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -178,6 +186,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( @@ -377,6 +392,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( @@ -602,6 +637,54 @@ 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 retries linked receiver inspection once for the batch`() = 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) + assertEquals(3, linkedPeerReads) + } + + @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.isSuccess, result.exceptionOrNull().toString()) + assertTrue(cacheData.value.contacts.isEmpty()) + assertTrue(cacheData.value.deletedContactCleanupPendingPublicKeys.isEmpty()) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } } @@ -624,6 +707,142 @@ 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 `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( + 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( @@ -700,579 +919,303 @@ 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 cached private resolution without live SDK session`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), - ), - ) + 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(PRIVATE_BOLT11), result) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) - } + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ), + result, + ) 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]) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `private payment list remains available when consumption persistence fails`() = 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), - 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)))) - 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 result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - val contactCache = cacheData.value.contacts.getValue(CONTACT_KEY) - assertTrue(contactCache.remoteEndpoints.isEmpty()) - assertEquals(7uL, contactCache.consumedPaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH]) + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `private payment filtering keeps the unattempted list available`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment retries a newer private list without public fallback`() = 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.WAITING_FOR_UPDATED_PAYMENT_LIST, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + 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)))) - 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]) - } - - @Test - fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), ), + result, ) - - 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) + verifyBlocking(paykitSdkService, times(2)) { + prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) } - } - - @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()) - } - 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, times(4)) { + 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() - } + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + val result = sut.beginPaymentRequest(request).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + ), + result, + ) + 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 payable check is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) + fun `beginPaymentRequest uses cached private resolution without live SDK session`() = test { + val request = paymentRequest() + whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(false) 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.P2wpkh, - value = PRIVATE_ADDRESS, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaykitPaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, ), ) - whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) } - .thenThrow(CancellationException("cancelled")) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } + 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 `beginSavedContactPayment does not fall back to public when private invoice decode is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) + 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.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.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenThrow(CancellationException("cancelled")) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + assertFailsWith { + sut.beginPaymentRequest(request).getOrThrow() } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1280,11 +1223,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 +1256,50 @@ 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, + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = acceptedEndpointIdentifiers, + ) + private fun privateListDeliveryReport( queuedCounterparties: List = emptyList(), clearedCounterparties: List = emptyList(), @@ -1352,6 +1325,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) + private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( + publishedPrivatePaymentReceiverPaths = setOf(receiverPath), + ) + private fun privateReceiverPathSelection( publishableReceiverPaths: List, linkableReceiverPaths: List = publishableReceiverPaths, 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..42ea724330 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10,10 +10,10 @@ 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 +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -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 @@ -33,8 +35,10 @@ 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 import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -72,12 +76,16 @@ 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.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 +115,11 @@ 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 -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @Suppress("LargeClass") @@ -142,6 +153,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 +172,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 +225,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } .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>()) } @@ -247,9 +262,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } .thenReturn(null) - whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any(), any()) } - .thenReturn(Result.success(Unit)) - whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(any(), any()) } + whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } .thenReturn(Result.success(Unit)) whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) .thenReturn(Result.failure(Exception("not mocked"))) @@ -299,6 +312,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + paykitPaymentRequestRepo = paykitPaymentRequestRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -339,6 +353,219 @@ 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() + clearInvocations(paykitPaymentRequestRepo) + + advanceTimeBy(59.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo).refresh() + + sut.stopPaykitPaymentRequestPolling() + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(120.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo, never()).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) + 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) + verify(privatePaykitRepo).beginPaymentRequest(request) + + advanceTimeBy(29.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + 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() + 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" @@ -1475,6 +1702,146 @@ 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, + ), + ), + ) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + + pendingPaykitPaymentRequests.value = listOf(request) + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + 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 `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") @@ -1499,9 +1866,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 +1881,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,28 +1895,181 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).discardRemoteOnchainEndpoints(contactKey, setOf(address)) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test - fun `private onchain contact payment stops when list consumption fails`() = test { - val address = "bcrt1qprivatecontact" - val contactKey = "pubkycontact" + 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) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { privatePaykitRepo.discardRemoteOnchainEndpoints(contactKey, setOf(address)) } - .thenReturn(Result.failure(AppError("backup failed"))) - setActiveContactPaymentContext(contactKey) + 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 = 1000u, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + 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 + 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" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.isPending(request)).thenReturn(false) + 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(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), @@ -1584,18 +2107,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) confirmCurrentPayment() - - verify(privatePaykitRepo, never()).discardRemoteOnchainEndpoints(any(), any()) } @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 +2141,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 +2168,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 +2194,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test @@ -1759,7 +2224,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any(), any()) + verify(privatePaykitRepo, never()).discardRemoteLightningEndpoints(any(), any()) } @Test @@ -1975,17 +2440,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") @@ -2051,11 +2505,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? { @@ -2064,6 +2519,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") @@ -2102,6 +2563,16 @@ 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, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), + ) } 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" }