diff --git a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt index 3ec708739b..3519b2186d 100644 --- a/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt +++ b/app/src/main/java/to/bitkit/data/PrivatePaykitStores.kt @@ -68,6 +68,7 @@ data class PrivatePaykitCacheData( @Serializable data class PrivatePaykitContactCacheData( val remoteEndpoints: List = emptyList(), + 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..97cb64ce32 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -0,0 +1,243 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.math.BigDecimal +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +data class PaykitPaymentRequestId( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, +) + +data class PaykitPaymentRequest( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val amountValue: String, + val amountSats: ULong, + val paymentReference: String, + val expiresAt: Instant?, + val acceptedPaymentEndpointIdentifiers: List, + val metadata: String, +) { + val id: PaykitPaymentRequestId + get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) + + fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true + + fun acceptsLightningInvoiceAmount(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 = CoroutineScope(SupervisorJob() + ioDispatcher) + private var expirationJob: Job? = null + private val _pendingRequests = MutableStateFlow>(emptyList()) + val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() + + suspend fun refresh(): Result { + val generation = stateGeneration.get() + return withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + runSuspendCatching { synchronizeLocked(generation) } + .onFailure { discardExpiredRequestsLocked() } + .getOrThrow() + } + }.onFailure { + Logger.warn("Failed to refresh incoming Paykit payment requests", it, context = TAG) + } + } + } + + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { + Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + } + + suspend fun clear() { + stateGeneration.incrementAndGet() + withContext(ioDispatcher) { + operationMutex.withLock { + expirationJob?.cancel() + expirationJob = null + _pendingRequests.update { emptyList() } + } + } + } + + private suspend fun synchronizeLocked(generation: Long) { + processPendingMessages() + paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) + val now = clock.now() + val requests = paykitSdkService.actionableReceivedPaymentRequests().mapNotNull { + it.toPaykitPaymentRequest(now) + } + if (stateGeneration.get() != generation) return + _pendingRequests.update { requests } + scheduleExpirationLocked() + } + + private suspend fun updateRequest( + request: PaykitPaymentRequest, + operation: suspend (PaykitPaymentRequest) -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + if (request.isExpired(clock.now())) { + discardExpiredRequestsLocked() + throw PaykitPaymentRequestError.RequestExpired + } + val current = _pendingRequests.value.firstOrNull { it.id == request.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + + operation(current) + _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } + discardExpiredRequestsLocked() + processPendingMessages() + } + } + } + + private suspend fun processPendingMessages() { + runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + .onSuccess(::logOutboundFailures) + .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } + } + + private fun logOutboundFailures(reports: List) { + reports.forEach { + val error = it.error ?: return@forEach + Logger.warn( + "Failed to deliver Paykit private messages to '${PubkyPublicKeyFormat.redacted(it.counterparty)}': " + + "'${error.redactedContext()}'", + context = TAG, + ) + } + } + + private fun logIntakeFailures(reports: List) { + reports.forEach { + val error = it.error ?: return@forEach + Logger.warn( + "Failed to receive Paykit private messages from '${PubkyPublicKeyFormat.redacted(it.counterparty)}': " + + "'${error.redactedContext()}'", + context = TAG, + ) + } + } + + private fun discardExpiredRequestsLocked() { + val now = clock.now() + _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + scheduleExpirationLocked() + } + + private fun scheduleExpirationLocked() { + expirationJob?.cancel() + expirationJob = null + + val nextExpiration = _pendingRequests.value.mapNotNull { it.expiresAt }.minOrNull() ?: return + val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) + expirationJob = repoScope.launch { + delay(delayDuration) + operationMutex.withLock { + expirationJob = null + discardExpiredRequestsLocked() + } + } + } +} + +private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") + +@Suppress("ReturnCount") +private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPaymentRequest? { + if (localRole != PaymentRequestLocalRole.PAYER || state != PaymentRequestLifecycleState.PROPOSED) return null + val requestTerms = terms ?: return null + if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null + val amountSats = requestTerms.amount.value.toSats() ?: return null + val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers + .filter { MethodId.fromRawValue(it) != null } + .distinct() + if (endpoints.isEmpty()) return null + + val expiresAt = requestTerms.proposalExpiresAt?.let { + runCatching { Instant.parse(it) }.getOrNull() ?: return null + } + if (expiresAt != null && expiresAt <= now) return null + + return PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = requestTerms.amount.value, + amountSats = amountSats, + paymentReference = requestTerms.paymentReference.exportText(), + expiresAt = expiresAt, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = requestTerms.metadata.exportText(), + ) +} + +private fun String.toSats(): ULong? { + if (!bitcoinAmountPattern.matches(this)) return null + return runCatching { + BigDecimal(this).movePointRight(8).toBigIntegerExact().toString().toULong() + }.getOrNull()?.takeIf { it > 0uL } +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitModels.kt index 7741558c9a..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,12 +35,14 @@ internal data class PrivatePaykitState( internal data class ContactState( var remoteEndpoints: List = emptyList(), + 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) }, + consumedPrivatePaymentListVersionsByReceiverPath = cache.consumedPrivatePaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = cache.localInvoicesByReceiverPath.mapValues { (_, invoice) -> StoredInvoice(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -48,11 +53,13 @@ internal data class ContactState( val hasCacheState: Boolean get() = publishedPrivatePaymentReceiverPaths.isNotEmpty() || remoteEndpoints.isNotEmpty() || + consumedPrivatePaymentListVersionsByReceiverPath.isNotEmpty() || localInvoicesByReceiverPath.isNotEmpty() || receivedInvoicePaymentHashes.isNotEmpty() fun cacheState() = PrivatePaykitContactCacheData( remoteEndpoints = remoteEndpoints.map { PrivatePaykitStoredPaymentEntryData(it.methodId, it.endpointData) }, + consumedPrivatePaymentListVersionsByReceiverPath = consumedPrivatePaymentListVersionsByReceiverPath, localInvoicesByReceiverPath = localInvoicesByReceiverPath.mapValues { (_, invoice) -> PrivatePaykitStoredInvoiceData(invoice.bolt11, invoice.paymentHash, invoice.expiresAt) }, @@ -66,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 a36e1af957..8d9475df07 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -1,12 +1,13 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.LinkedPeerState -import com.synonym.paykit.PaymentEndpointReservationInput -import com.synonym.paykit.PaymentEndpointSource +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 @@ -21,6 +22,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus @@ -29,10 +32,13 @@ import to.bitkit.async.appScope import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.di.json import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService +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 @@ -100,6 +106,22 @@ 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 publicKeys: List, + val hadInvalidKey: Boolean, + ) + + private data class LinkedReceiverPathInspection( + val receiverPaths: Set, + val error: Throwable?, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -288,19 +310,42 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { val normalizedKey = knownSavedContact(publicKey) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + beginContactPayment(normalizedKey, paymentRequest = null).getOrThrow() + } + } - val result = beginContactPayment(normalizedKey).getOrElse { - if (it is CancellationException) throw it - Logger.warn( - "Failed to resolve Paykit contact payment for '${redacted(normalizedKey)}'", - it, - context = TAG, - ) - return@runSuspendCatching publicPaykitRepo.beginPayment(normalizedKey).getOrThrow() - } - result + suspend fun beginPaymentRequest(request: PaykitPaymentRequest): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + if (request.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + val publicKey = normalizedPublicKey(request.counterparty) ?: throw PrivatePaykitError.InvalidPublicKey + beginContactPayment(publicKey, request).getOrThrow() + } + }.onFailure { + Logger.warn("Failed to present incoming Paykit payment request", it, context = TAG) + } + + suspend fun consumePrivatePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val contactState = ensureState().contacts.getOrPut(normalizedKey) { ContactState() } + val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] + if (consumedVersion != null && context.paymentListVersion <= consumedVersion) { + throw PrivatePaykitError.PaymentListAlreadyConsumed } + + contactState.consumedPrivatePaymentListVersionsByReceiverPath = + contactState.consumedPrivatePaymentListVersionsByReceiverPath + + (context.receiverPath to context.paymentListVersion) + contactState.remoteEndpoints = emptyList() + persistState(markWalletBackup = true) } + }.onFailure { + Logger.warn("Failed to consume private Paykit payment details", it, context = TAG) + } suspend fun discardRemoteLightningEndpoints( publicKey: String, @@ -414,7 +459,17 @@ class PrivatePaykitRepo @Inject constructor( withContext(serializedDispatcher) { runSuspendCatching { pubkyService.currentPublicKey() ?: return@runSuspendCatching null - paykitSdkService.exportBackupState() + json.encodeToString( + PrivatePaykitBackup( + sdkState = paykitSdkService.exportBackupState(), + consumedPrivatePaymentListVersions = ensureState().contacts + .mapNotNull { (publicKey, contactState) -> + contactState.consumedPrivatePaymentListVersionsByReceiverPath + .takeIf { it.isNotEmpty() } + ?.let { publicKey to it } + }.toMap(), + ) + ) } } @@ -427,73 +482,175 @@ class PrivatePaykitRepo @Inject constructor( if (backup == null) { paykitSdkService.clearState() } else { - paykitSdkService.restoreBackupState(backup) + val decoded = json.decodeFromString(backup) + paykitSdkService.restoreBackupState(decoded.sdkState) + decoded.consumedPrivatePaymentListVersions.forEach { (publicKey, versions) -> + ensureState().contacts.getOrPut(publicKey) { ContactState() } + .consumedPrivatePaymentListVersionsByReceiverPath = versions + } } persistState(preserveCleanupMarkers = false) notifyBackupStateChanged() } } - private suspend fun beginContactPayment(publicKey: String): Result = + private suspend fun beginContactPayment( + publicKey: String, + paymentRequest: PaykitPaymentRequest?, + ): Result = withContext(serializedDispatcher) { runSuspendCatching { - pubkyService.currentPublicKey() ?: throw PublicPaykitError.SessionNotActive - if (canPublishPrivateEndpoints()) { - publishLocalEndpoints( - publicKeys = listOf(publicKey), - reason = "payment", - ).onFailure { - Logger.warn( - "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", - it, - context = TAG, - ) + if (!hasLiveSessionForCurrentProfile()) { + if (paymentRequest != null) throw PrivatePaykitError.PrivateUnavailable + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + } + if (paymentRequest == null) refreshPrivateEndpointsBeforePayment(publicKey) + + val receiverPath = paymentRequest?.counterpartyReceiverPath ?: PaykitReceiverPaths.WALLET + val consumedVersion = ensureState().contacts[publicKey] + ?.consumedPrivatePaymentListVersionsByReceiverPath + ?.get(receiverPath) + val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, "btc") } + val prepared = preparePrivateContactPayment( + publicKey = publicKey, + receiverPath = receiverPath, + consumedVersion = consumedVersion, + amount = amount, + allowPublicResolution = paymentRequest == null, + ) + ?: if (paymentRequest == null) { + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + } else { + throw PrivatePaykitError.PrivateUnavailable } + val resolution = prepared.resolution + val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) + if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { + return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } - val resolution = paykitSdkService.prepareAndResolveContactPayment( - counterparty = publicKey, - receiverPath = PaykitReceiverPaths.WALLET, - includePublicEndpoints = true, + privatePaymentResult( + publicKey = publicKey, + receiverPath = receiverPath, + resolution = resolution, + acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), ) - val privateEndpoints = resolution.payableEndpoints - .filter { it.source == PaymentEndpointSource.PRIVATE_PAYMENT_LIST } - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } + } + } + + 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, + ) + } + } - cacheResolvedPrivateEndpoints(publicKey, privateEndpoints) + private suspend fun preparePrivateContactPayment( + publicKey: String, + receiverPath: String, + consumedVersion: ULong?, + amount: PaymentAmountContext?, + allowPublicResolution: Boolean, + ): PaykitPreparedPrivateContactPayment? { + val result = runSuspendCatching { + paykitSdkService.prepareAndResolvePrivateContactPayment( + counterparty = publicKey, + receiverPath = receiverPath, + afterPrivatePaymentListVersion = consumedVersion, + amount = amount, + ) + } + val error = result.exceptionOrNull() ?: return result.getOrThrow() + if (!allowPublicResolution) throw error + if (!canUsePublicPayment(currentLinkState(publicKey, receiverPath))) throw error + + Logger.warn( + "Using public Paykit resolution for '${redacted(publicKey)}'", + error, + context = TAG, + ) + return null + } - val privatePayable = privatePayableEndpoints(privateEndpoints, publicKey) - if (privatePayable.isNotEmpty()) { - return@runSuspendCatching PublicPaykitPaymentResult.Opened( - PublicPaykitRepo.paymentRequest(privatePayable), - ) - } + 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 + } - if (resolution.privateState == ContactPaymentResolutionPrivateState.RECOVERY_PENDING) { - schedulePendingPrivateMessageDrainRetries( - reason = "payment recovery", - retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, PaykitReceiverPaths.WALLET)), - ) - } + val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) + val paymentListVersion = resolution.privatePaymentListVersion + if (privatePayable.isNotEmpty() && paymentListVersion != null) { + return PublicPaykitPaymentResult.Opened( + paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), + privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), + ) + } - val publicEndpoints = resolution.payableEndpoints - .filter { it.source == PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT } - .mapNotNull { PublicPaykitRepo.parseEndpoint(it.identifier, it.payload) } - val publicPayable = publicPaykitRepo.payableEndpoints(publicEndpoints) - if (publicPayable.isNotEmpty()) { - return@runSuspendCatching PublicPaykitPaymentResult.Opened( - PublicPaykitRepo.paymentRequest(publicPayable), - ) - } + if ( + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || + resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST + ) { + schedulePendingPrivateMessageDrainRetries( + reason = "payment recovery", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + ) + } + if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { + return PublicPaykitPaymentResult.WaitingForUpdatedPaymentList + } - if (privateEndpoints.isEmpty() && publicEndpoints.isEmpty()) { - PublicPaykitPaymentResult.NoEndpoint - } else { - PublicPaykitPaymentResult.NotOpened - } - } + return if (acceptedEndpoints.isEmpty()) { + PublicPaykitPaymentResult.NoEndpoint + } else { + PublicPaykitPaymentResult.NotOpened + } + } + + private suspend fun currentLinkState( + publicKey: String, + receiverPath: String, + preparedState: LinkedPeerState? = null, + ): LinkedPeerState? = preparedState ?: paykitSdkService.linkedPeers().firstOrNull { + PubkyPublicKeyFormat.matches(it.counterparty, publicKey) && it.counterpartyReceiverPath == receiverPath + }?.state + + private fun canUsePublicPayment( + linkState: LinkedPeerState?, + resolutionStatus: PrivatePaymentResolutionStatus? = null, + resolutionState: PrivatePaymentResolutionState? = null, + ): Boolean { + if ( + resolutionStatus == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST || + resolutionState != null && resolutionState != PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT + ) { + return false } + return when (linkState) { + null, LinkedPeerState.NOT_LINKED, LinkedPeerState.LINKING -> true + LinkedPeerState.LINKED, + LinkedPeerState.RECOVERY_REQUIRED, + LinkedPeerState.BLOCKED, + LinkedPeerState.UNKNOWN, + -> false + } + } + private suspend fun publishLocalEndpoints( publicKeys: Collection, reason: String, @@ -552,6 +709,7 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull(reason) for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -568,27 +726,21 @@ 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 linkedReceiverPathInspection = inspectLinkedReceiverPaths( + publicKey, + linkedReceiverPathsSnapshot, + reason, + ) + firstError = firstError ?: linkedReceiverPathInspection.error val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths = linkedReceiverPathInspection.receiverPaths, ) - (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> - linkRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - } + linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) cleanupReceiverPaths.forEach { receiverPath -> updates += PrivatePaymentListReservationUpdateInput( @@ -640,6 +792,21 @@ class PrivatePaykitRepo @Inject constructor( drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } + private suspend fun preparePrivateLinks( + publicKey: String, + receiverPaths: Collection, + reason: String, + ): List = receiverPaths.distinct().map { receiverPath -> + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + } + PrivateMessageDrainRetryKey(publicKey, receiverPath) + } + private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, @@ -686,6 +853,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, @@ -994,7 +1173,7 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, receiverPath: String, endpoint: Endpoint, - ): PaymentEndpointReservationInput { + ): PrivatePaymentEndpointReservationInput { val contactState = state?.contacts?.get(publicKey) val attribution = if (endpoint.methodId == MethodId.Bolt11) { val paymentHash = localInvoice(publicKey, receiverPath)?.takeIf { it.bolt11 == endpoint.value }?.paymentHash @@ -1015,7 +1194,7 @@ class PrivatePaykitRepo @Inject constructor( ?.takeIf { endpoint.methodId == MethodId.Bolt11 && it.bolt11 == endpoint.value } ?.let { Instant.ofEpochSecond(it.expiresAt).toString() } - return PaymentEndpointReservationInput( + return PrivatePaymentEndpointReservationInput( reservationId = privateReservationId(publicKey, receiverPath, endpoint), identifier = endpoint.methodId.rawValue, payload = endpoint.rawPayload, @@ -1039,41 +1218,96 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - val firstError = keys.mapNotNull { publicKey -> - removePublishedEndpoints(publicKey).exceptionOrNull() - }.firstOrNull() - if (firstError != null) throw firstError - } + val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) + .distinct() + removePublishedEndpoints(keys) } - private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { - runSuspendCatching { - var firstError: Throwable? = null - receiverPathsForCleanup(publicKey).forEach { receiverPath -> - val result = runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList( - counterparty = publicKey, - receiverPath = receiverPath, + private suspend fun removePublishedEndpoints(publicKey: String): Result = + removePublishedEndpoints(listOf(publicKey)) + + private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + val publicKeys = normalizedBatch.publicKeys + var firstError: Throwable? = PrivatePaykitError.InvalidPublicKey.takeIf { + normalizedBatch.hadInvalidKey + } + if (publicKeys.isEmpty()) { + firstError?.let { throw it } + return@runSuspendCatching Unit + } + + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull("private endpoint cleanup") + val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPathsSnapshot) + val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() + firstError = firstError ?: preparation.firstError + + if (preparation.clearedRetryKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + advancingLinksFor = preparation.clearedRetryKeys, ) - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable + val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys.map { it.publicKey } + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } - val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) - drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) - if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { + } + + val successfulPublicKeys = publicKeys.filterNot { it in failedPublicKeys } + clearPublishedEndpointCache(successfulPublicKeys) + + firstError?.let { throw it } + Unit + } + } + + private suspend fun clearPrivatePaymentLists( + publicKeys: Collection, + linkedReceiverPathsSnapshot: Map>?, + ): PrivateEndpointCleanupPreparation { + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = null + + publicKeys.forEach { publicKey -> + val contactLinkedReceiverPaths = linkedReceiverPaths( + publicKey = publicKey, + snapshot = linkedReceiverPathsSnapshot, + ).onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during cleanup", + it, + context = TAG, + ) + }.getOrDefault(emptySet()) + receiverPathsForCleanup( + publicKey = publicKey, + linkedReceiverPaths = contactLinkedReceiverPaths, + ).forEach { receiverPath -> + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } - } - if (result.isFailure) { - firstError = firstError ?: result.exceptionOrNull() + }.onSuccess { + clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } + } - firstError?.let { throw it } + return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) + } + private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() contactState.localInvoicesByReceiverPath = emptyMap() @@ -1083,6 +1317,9 @@ class PrivatePaykitRepo @Inject constructor( } } updateDeletedContactCleanupPending(publicKey, isPending = false) + } + + if (publicKeys.isNotEmpty()) { persistState(markWalletBackup = true) } } @@ -1095,42 +1332,85 @@ 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 linkedReceiverPathsSnapshotOrNull(reason: String): Map>? = + runSuspendCatching { linkedReceiverPathsByPublicKey() } + .onFailure { + Logger.warn( + "Failed to inspect private Paykit links during '$reason'; retrying per contact", + it, + context = TAG, + ) + }.getOrNull() + + private suspend fun linkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + ): Result> = if (snapshot != null) { + Result.success(snapshot[publicKey].orEmpty()) + } else { + runSuspendCatching { linkedReceiverPathsByPublicKey()[publicKey].orEmpty() } + } + + private suspend fun inspectLinkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + reason: String, + ): LinkedReceiverPathInspection { + val result = linkedReceiverPaths(publicKey, snapshot) + val error = result.exceptionOrNull() + if (error != null) { + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + return LinkedReceiverPathInspection(result.getOrDefault(emptySet()), error) + } + + private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { + var hadInvalidKey = false + val normalizedKeys = publicKeys.mapNotNull { publicKey -> + normalizedPublicKey(publicKey) ?: run { + hadInvalidKey = true + null + } + }.distinct() + return NormalizedPublicKeyBatch(normalizedKeys, hadInvalidKey) } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { @@ -1238,11 +1518,11 @@ class PrivatePaykitRepo @Inject constructor( lightningRepo.lightningState.value.nodeLifecycleState.isRunning() } - private suspend fun hasLiveSessionForCurrentProfile(): Boolean = runSuspendCatching { - pubkyService.currentPublicKey() ?: return@runSuspendCatching false - val status = paykitSdkService.identityStatus() ?: return@runSuspendCatching false - status.liveSessionAvailable - }.getOrDefault(false) + private suspend fun hasLiveSessionForCurrentProfile(): Boolean { + pubkyService.currentPublicKey() ?: return false + val status = paykitSdkService.identityStatus() ?: return false + return status.liveSessionAvailable + } private suspend fun isContactSharingCleanupPending(): Boolean = cacheStore.data.first().cleanupPending diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 170e1a9481..91e277bb9c 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -47,11 +47,21 @@ sealed class PublicPaykitError(message: String) : AppError(message) { } sealed interface PublicPaykitPaymentResult { - data class Opened(val paymentRequest: String) : PublicPaykitPaymentResult + data class Opened( + val paymentRequest: String, + val privatePaymentContext: PrivatePaykitPaymentContext? = null, + ) : PublicPaykitPaymentResult + data object NoEndpoint : PublicPaykitPaymentResult data object NotOpened : PublicPaykitPaymentResult + data object WaitingForUpdatedPaymentList : PublicPaykitPaymentResult } +data class PrivatePaykitPaymentContext( + val receiverPath: String, + val paymentListVersion: ULong, +) + @OptIn(ExperimentalTime::class) @Suppress("LongParameterList") @Singleton diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 25ea826aa3..ecede15c42 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -2,8 +2,6 @@ package to.bitkit.services import android.content.Context import com.synonym.bitkitcore.mnemonicToSeed -import com.synonym.paykit.ContactPaymentResolution -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate @@ -11,6 +9,8 @@ import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport import com.synonym.paykit.IdentityStatus 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 @@ -19,14 +19,22 @@ import com.synonym.paykit.PaykitReceiverCapabilities import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults -import com.synonym.paykit.PaymentEndpointCandidate -import com.synonym.paykit.PaymentEndpointReservationCancellation -import com.synonym.paykit.PaymentEndpointSelectionRequest -import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.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 +import com.synonym.paykit.PrivatePaymentEndpointReservationCancellation +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 @@ -34,11 +42,11 @@ import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult +import com.synonym.paykit.PublicContactPaymentResolution +import com.synonym.paykit.PublicPaymentEndpointCandidate +import com.synonym.paykit.PublicPaymentEndpointSelectionRequest +import com.synonym.paykit.PublicReceivingDetail import com.synonym.paykit.ReceiverNoiseSecretKey -import com.synonym.paykit.ReceivingDetail -import com.synonym.paykit.ReceivingDetailReservationResponse -import com.synonym.paykit.ReceivingDetailReservationResponseKind -import com.synonym.paykit.ReceivingDetailScope import com.synonym.paykit.SdkPaymentAdapter import com.synonym.paykit.SdkPubkySessionProvider import com.synonym.paykit.SdkStateBlob @@ -77,14 +85,23 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton -data class PaykitContactPaymentResolution( - val privateState: ContactPaymentResolutionPrivateState, +data class PaykitPreparedPrivateContactPayment( + val resolution: PaykitPrivateContactPaymentResolution, + val linkState: LinkedPeerState?, +) + +data class PaykitPrivateContactPaymentResolution( + val status: PrivatePaymentResolutionStatus, + val state: PrivatePaymentResolutionState, + val privatePaymentListVersion: ULong?, + val payableEndpoints: List, +) + +data class PaykitPublicContactPaymentResolution( val payableEndpoints: List, ) data class PaykitResolvedPaymentEndpoint( - val counterparty: String, - val source: PaymentEndpointSource, val identifier: String, val payload: String, ) @@ -105,7 +122,7 @@ internal object PaykitReceiverPaths { } @Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, @@ -467,7 +484,7 @@ class PaykitSdkService @Inject constructor( isSetup.await() return operationMutex.withLock { withStateRevisionTracking { handle -> - handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toReceivingDetail() }) + handle.syncPublicEndpointsWithReceivingDetails(endpoints.map { it.toPublicReceivingDetail() }) } } } @@ -514,24 +531,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 { @@ -546,50 +583,63 @@ class PaykitSdkService @Inject constructor( } } - suspend fun prepareAndResolveContactPayment( + suspend fun prepareAndResolvePrivateContactPayment( counterparty: String, receiverPath: String, - includePublicEndpoints: Boolean, - ): PaykitContactPaymentResolution { + afterPrivatePaymentListVersion: ULong?, + amount: PaymentAmountContext? = null, + ): PaykitPreparedPrivateContactPayment { isSetup.await() val prepared = operationMutex.withLock { withStateRevisionTracking { handle -> - handle.prepareAndResolveContactPayment( + handle.prepareAndResolvePrivateContactPayment( counterparty = counterparty, counterpartyReceiverPath = receiverPath, - amount = null, - includePublicEndpoints = includePublicEndpoints, + amount = amount, + afterPrivatePaymentListVersion = afterPrivatePaymentListVersion, maxAdvanceSteps = 8u, ) } } - return prepared.resolution.toPaykitContactPaymentResolution() + 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 ContactPaymentResolution.toPaykitContactPaymentResolution(): PaykitContactPaymentResolution { - return PaykitContactPaymentResolution( - privateState = privateState, + private fun PrivateContactPaymentResolution.toPaykitPrivateContactPaymentResolution() = + PaykitPrivateContactPaymentResolution( + status = status, + state = state, + privatePaymentListVersion = privatePaymentListVersion, + payableEndpoints = payableEndpoints.map { + PaykitResolvedPaymentEndpoint( + identifier = it.identifier, + payload = it.target.payload.exportText(), + ) + }, + ) + + private fun PublicContactPaymentResolution.toPaykitPublicContactPaymentResolution() = + PaykitPublicContactPaymentResolution( payableEndpoints = payableEndpoints.map { PaykitResolvedPaymentEndpoint( - counterparty = it.counterparty, - source = it.source, identifier = it.identifier, payload = it.target.payload.exportText(), ) }, ) - } suspend fun exportBackupState(): String { isSetup.await() @@ -707,7 +757,7 @@ class PaykitSdkService @Inject constructor( val status = handle.identityStatus() return PaykitReceiverCapabilities( privatePayments = status?.liveSessionAvailable == true, - paymentRequests = false, + paymentRequests = status?.liveSessionAvailable == true, receipts = false, outgoingPayments = true, ) @@ -1008,20 +1058,42 @@ internal class PaykitReceiverNoiseKeyStore( } class PaykitSdkPaymentAdapter : SdkPaymentAdapter { - override fun currentReceivingDetails(scope: ReceivingDetailScope): List = emptyList() + override fun currentPublicReceivingDetails(): List = emptyList() + + override fun currentPrivateReceivingDetails( + counterparty: String, + counterpartyReceiverPath: String, + ): List = emptyList() - override fun reserveReceivingDetails( + override fun reservePrivateReceivingDetails( counterparty: String, counterpartyReceiverPath: String, - ): ReceivingDetailReservationResponse = - ReceivingDetailReservationResponse( - kind = ReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, + ): PrivateReceivingDetailReservationResponse = + PrivateReceivingDetailReservationResponse( + kind = PrivateReceivingDetailReservationResponseKind.USE_CURRENT_RECEIVING_DETAILS, reservations = emptyList(), ) - override fun cancelReceivingDetailReservation(cancellation: PaymentEndpointReservationCancellation) = Unit + override fun cancelPrivateReceivingDetailReservation( + cancellation: PrivatePaymentEndpointReservationCancellation, + ) = Unit + + override fun selectPublicPaymentEndpointIds(request: PublicPaymentEndpointSelectionRequest): List { + val parsed = request.candidates.mapNotNull { candidate -> + PublicPaykitRepo.parseEndpoint( + methodId = candidate.identifier, + endpointData = candidate.payload.exportText(), + )?.let { candidate.candidateId to it } + } + return PublicPaykitRepo.payablePreferenceOrder.flatMap { methodId -> + parsed.mapNotNull { (id, endpoint) -> id.takeIf { endpoint.methodId == methodId } } + } + } + + override fun buildPublicPaymentTarget(endpoint: PublicPaymentEndpointCandidate): PaymentTarget = + PaymentTarget(endpoint.payload) - override fun selectPaymentEndpointIds(request: PaymentEndpointSelectionRequest): List { + override fun selectPrivatePaymentEndpointIds(request: PrivatePaymentEndpointSelectionRequest): List { val parsed = request.candidates.mapNotNull { candidate -> PublicPaykitRepo.parseEndpoint( methodId = candidate.identifier, @@ -1033,11 +1105,11 @@ class PaykitSdkPaymentAdapter : SdkPaymentAdapter { } } - override fun buildPaymentTarget(endpoint: PaymentEndpointCandidate): PaymentTarget = + override fun buildPrivatePaymentTarget(endpoint: PrivatePaymentEndpointCandidate): PaymentTarget = PaymentTarget(endpoint.payload) } -private fun Endpoint.toReceivingDetail() = ReceivingDetail( +private fun Endpoint.toPublicReceivingDetail() = PublicReceivingDetail( identifier = methodId.rawValue, payload = PaymentPayload(rawPayload), ) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 505bacbe16..b315141988 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -273,9 +273,11 @@ fun ContentView( blocktankViewModel.refreshOrders() appViewModel.refreshPublicPaykitEndpoints() appViewModel.refreshPrivatePaykitEndpoints() + appViewModel.startPaykitPaymentRequestPolling() } Lifecycle.Event.ON_STOP -> { + appViewModel.stopPaykitPaymentRequestPolling() val keptAliveByService = notificationsGranted && keepActiveInBackground && appViewModel.isForegroundServiceRunning() @@ -291,6 +293,7 @@ fun ContentView( lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) + appViewModel.stopPaykitPaymentRequestPolling() } } @@ -1201,8 +1204,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)) }, onEditContact = { navController.navigateTo(Routes.EditContact(it)) }, 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 1d35947bd6..8a695b7e71 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -126,6 +126,8 @@ class AddContactViewModel @Inject constructor( showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 08caea71a5..e99f120562 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 @@ -29,6 +29,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 @@ -50,7 +51,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, onEditContact: (String) -> Unit = {}, ) { @@ -60,7 +61,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) } } } 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 dd15c57f51..a378e93e7d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -23,6 +23,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 @@ -102,11 +103,19 @@ class ContactDetailViewModel @Inject constructor( .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> - _effects.emit(ContactDetailEffect.OpenPayment(result.paymentRequest, publicKey)) + _effects.emit( + ContactDetailEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + ) + ) PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { @@ -195,5 +204,9 @@ 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 } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 8dd6a71402..5d0929bdce 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -211,6 +211,7 @@ private fun Content( SendContactTopBar( titleText = when { + uiState.isPaymentRequest -> stringResource(R.string.wallet__payment_request) isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index 1acbbc9fe2..b159762811 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BodySSB @@ -41,14 +42,15 @@ import to.bitkit.ui.theme.Colors fun SendContactSelectScreen( viewModel: SendContactSelectViewModel, onBack: () -> Unit, - onOpenPayment: (String, String) -> Unit, + onOpenPayment: (String, String, PrivatePaykitPaymentContext?) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is SendContactSelectEffect.OpenPayment -> onOpenPayment(it.paymentRequest, it.publicKey) + is SendContactSelectEffect.OpenPayment -> + onOpenPayment(it.paymentRequest, it.publicKey, it.privatePaymentContext) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index 54a9c355c4..201ef5e9d7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -69,11 +70,19 @@ class SendContactSelectViewModel @Inject constructor( .onSuccess { result -> when (result) { is PublicPaykitPaymentResult.Opened -> - _effects.emit(SendContactSelectEffect.OpenPayment(result.paymentRequest, publicKey)) + _effects.emit( + SendContactSelectEffect.OpenPayment( + result.paymentRequest, + publicKey, + result.privatePaymentContext, + ) + ) PublicPaykitPaymentResult.NoEndpoint -> showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + showPayError(R.string.slashtags__error_pay_empty_msg) } } .onFailure { @@ -105,5 +114,9 @@ data class SendContactSelectUiState( ) sealed interface SendContactSelectEffect { - data class OpenPayment(val paymentRequest: String, val publicKey: String) : SendContactSelectEffect + data class OpenPayment( + val paymentRequest: String, + val publicKey: String, + val privatePaymentContext: PrivatePaykitPaymentContext?, + ) : SendContactSelectEffect } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 1f54c44421..3b7257b071 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -155,8 +155,8 @@ fun SendSheet( appViewModel.clearActiveContactPaymentContext() navController.popBackStack() }, - onOpenPayment = { paymentRequest, publicKey -> - appViewModel.openContactPayment(paymentRequest, publicKey) + onOpenPayment = { paymentRequest, publicKey, privatePaymentContext -> + appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3449a58acf..76a20e3c9d 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 @@ -130,13 +132,18 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo @@ -208,6 +215,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, @@ -269,6 +277,9 @@ class AppViewModel @Inject constructor( private val contactPaymentContextLock = Any() private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() + private val presentedPaymentRequestIds = mutableSetOf() + private var isPresentingPaymentRequest = false + private var paykitPaymentRequestPollingJob: Job? = null private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -388,6 +399,8 @@ class AppViewModel @Inject constructor( observePublicPaykitEndpoints() observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() + observePaykitPaymentRequestConnectivity() + observeIncomingPaykitPaymentRequests() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -507,6 +520,7 @@ class AppViewModel @Inject constructor( .collect { state -> if (!state.isPaykitEnabled || state.publicKey == null) { lastPrivatePaykitContactKeys = emptySet() + paykitPaymentRequestRepo.clear() return@collect } @@ -533,6 +547,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + refreshIncomingPaykitPaymentRequests() lastPrivatePaykitContactKeys = state.contactKeys } } @@ -553,6 +568,70 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + refreshIncomingPaykitPaymentRequests() + } + + private fun observePaykitPaymentRequestConnectivity() { + viewModelScope.launch { + isOnline + .drop(1) + .filter { it == ConnectivityState.CONNECTED } + .collect { refreshIncomingPaykitPaymentRequests() } + } + } + + private suspend fun refreshIncomingPaykitPaymentRequests() { + if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return + paykitPaymentRequestRepo.refresh().onSuccess { presentNextIncomingPaykitPaymentRequest() } + } + + fun startPaykitPaymentRequestPolling() { + if (paykitPaymentRequestPollingJob?.isActive == true) return + + paykitPaymentRequestPollingJob = viewModelScope.launch { + while (true) { + delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL) + refreshIncomingPaykitPaymentRequests() + } + } + } + + fun stopPaykitPaymentRequestPolling() { + paykitPaymentRequestPollingJob?.cancel() + paykitPaymentRequestPollingJob = null + } + + private fun observeIncomingPaykitPaymentRequests() { + viewModelScope.launch { + currentSheet.collect { + if (it == null) presentNextIncomingPaykitPaymentRequest() + } + } + } + + private suspend fun presentNextIncomingPaykitPaymentRequest() { + val requests = paykitPaymentRequestRepo.pendingRequests.value + presentedPaymentRequestIds.retainAll(requests.mapTo(mutableSetOf()) { it.id }) + if (currentSheet.value != null || isPresentingPaymentRequest || hasActiveContactPaymentContext()) return + val request = requests.firstOrNull { it.id !in presentedPaymentRequestIds } ?: return + presentIncomingPaykitPaymentRequest(request) + } + + private suspend fun presentIncomingPaykitPaymentRequest(request: PaykitPaymentRequest) { + isPresentingPaymentRequest = true + privatePaykitRepo.beginPaymentRequest(request) + .onSuccess { result -> + if (result is PublicPaykitPaymentResult.Opened && currentSheet.value == null) { + presentedPaymentRequestIds += request.id + openContactPayment( + paymentRequest = result.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = result.privatePaymentContext, + incomingPaymentRequest = request, + ) + } + } + isPresentingPaymentRequest = false } private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { @@ -1624,9 +1703,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) + activeContactPaymentContext = ContactPaymentContext( + publicKey = publicKey, + privatePaymentContext = privatePaymentContext, + incomingPaymentRequest = incomingPaymentRequest, + ) } onScanResult(paymentRequest) } @@ -1647,8 +1735,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 @@ -1806,6 +1895,10 @@ class AppViewModel @Inject constructor( activeContactPaymentContext?.publicKey } + private fun activeIncomingPaymentRequest() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest + } + private fun activeContactPaymentProfile(): PubkyProfile? { val publicKey = activeContactPaymentPublicKey() ?: return null return pubkyRepo.contacts.value.firstOrNull { @@ -1845,8 +1938,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?.acceptsLightningInvoiceAmount(it.amountSatoshis) != false + } + val amount = incomingPaymentRequest?.amountSats + ?: lnInvoice?.amountSatoshis?.takeIf { it > 0uL } + ?: invoice.amountSatoshis _sendUiState.update { it.copy( address = invoice.address, @@ -1860,6 +1958,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) @@ -1935,18 +2078,25 @@ class AppViewModel @Inject constructor( return } + val incomingPaymentRequest = activeIncomingPaymentRequest() + if (incomingPaymentRequest?.acceptsLightningInvoiceAmount(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), @@ -1960,7 +2110,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( - amount = invoice.amountSatoshis, + amount = amount, addressInput = scanResult, isAddressInputValid = true, decodedInvoice = invoice, @@ -1968,7 +2118,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) @@ -1984,9 +2134,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, @@ -1997,7 +2158,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( @@ -2007,11 +2168,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, ) @@ -2290,6 +2451,22 @@ class AppViewModel @Inject constructor( private suspend fun proceedWithPayment() { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable + if (hasMismatchedIncomingPaymentRequest()) { + rejectMismatchedPaymentRequest() + return + } + + acceptIncomingPaymentRequestIfNeeded().onFailure { + toast(it) + return + } + + consumePrivatePaymentListIfNeeded().onFailure { + toast(it) + hideSheet() + return + } + val amount = _sendUiState.value.amount val lnurl = _sendUiState.value.lnurl @@ -2321,7 +2498,6 @@ class AppViewModel @Inject constructor( sendOnchain(address, amount, tags = tags) .onSuccess { txId -> - discardContactOnchainEndpoint(contactPublicKey, address) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -2374,7 +2550,6 @@ class AppViewModel @Inject constructor( } sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> - discardContactLightningEndpoint(contactPublicKey, actualPaymentHash) Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -2386,16 +2561,12 @@ class AppViewModel @Inject constructor( ) }.onFailure { if (it is PaymentPendingException) { - discardContactLightningEndpoint(contactPublicKey, it.paymentHash) Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) preserveContactPaymentContext(it.paymentHash) setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } - if (contactPublicKey != null) { - discardContactLightningEndpoint(contactPublicKey, paymentHash) - } // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -2408,6 +2579,25 @@ class AppViewModel @Inject constructor( } } + private fun hasMismatchedIncomingPaymentRequest(): Boolean { + val incomingPaymentRequest = activeIncomingPaymentRequest() ?: 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.acceptsLightningInvoiceAmount(lightningInvoice.amountSatoshis) + } + + 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) @@ -2703,7 +2893,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 { @@ -2717,6 +2910,7 @@ class AppViewModel @Inject constructor( speed = speed, feeRates = rates, contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, ) } } @@ -3067,26 +3261,18 @@ class AppViewModel @Inject constructor( } } - private suspend fun discardContactLightningEndpoint(contactPublicKey: String?, paymentHash: String) { - if (contactPublicKey == null) return - privatePaykitRepo.discardRemoteLightningEndpoints(contactPublicKey, setOf(paymentHash)).onFailure { - Logger.warn( - "Failed to discard private Paykit invoice for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", - it, - context = TAG, - ) - } + private suspend fun consumePrivatePaymentListIfNeeded(): Result { + val context = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + ?: return Result.success(Unit) + val privatePaymentContext = context.privatePaymentContext ?: return Result.success(Unit) + return privatePaykitRepo.consumePrivatePaymentList(context.publicKey, privatePaymentContext) } - private suspend fun discardContactOnchainEndpoint(contactPublicKey: String?, address: String) { - if (contactPublicKey == null) return - privatePaykitRepo.discardRemoteOnchainEndpoints(contactPublicKey, setOf(address)).onFailure { - Logger.warn( - "Failed to discard private Paykit address for '${PubkyPublicKeyFormat.redacted(contactPublicKey)}'", - it, - context = TAG, - ) - } + private suspend fun acceptIncomingPaymentRequestIfNeeded(): Result { + val request = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest + } ?: return Result.success(Unit) + return paykitPaymentRequestRepo.accept(request) } fun handleDeeplinkIntent(intent: Intent) { @@ -3255,6 +3441,7 @@ class AppViewModel @Inject constructor( fun onHomeResumed() { checkTimedSheets() hwWalletRepo.onAppForegrounded() + viewModelScope.launch { refreshIncomingPaykitPaymentRequests() } } fun onLeftHome() = timedSheetManager.onHomeScreenExited() @@ -3310,6 +3497,7 @@ class AppViewModel @Inject constructor( private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L + private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVAL = 30.seconds private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" @@ -3350,6 +3538,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) { @@ -3367,7 +3556,11 @@ sealed class SendFee(open val value: Long) { enum class SendMethod { ONCHAIN, LIGHTNING } -data class ContactPaymentContext(val publicKey: String) +data class ContactPaymentContext( + val publicKey: String, + val privatePaymentContext: PrivatePaykitPaymentContext? = null, + val incomingPaymentRequest: PaykitPaymentRequest? = null, +) private data class PaykitContactSyncState( val publicKey: String?, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e205f76fb7..cd3e3fa097 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1145,6 +1145,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..3c106dda84 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -0,0 +1,192 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" + private const val COUNTERPARTY = "pubkypayee" + private val START_TIME = Instant.parse("2027-01-15T08:00:00Z") + private val PAYMENT_REFERENCE = mock { + on { exportText() } doReturn "invoice-123" + } + private val METADATA = mock { + on { exportText() } doReturn """{"order":"123"}""" + } + } + + private val paykitSdkService = mock() + private var schedulerOriginMillis = 0L + private val clock = object : Clock { + override fun now(): Instant = START_TIME.plus( + (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, + ) + } + private lateinit var sut: PaykitPaymentRequestRepo + + @Before + fun setUp() = test { + schedulerOriginMillis = testDispatcher.scheduler.currentTime + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(emptyList()) + sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, clock) + } + + @After + fun tearDown() = test { + sut.clear() + } + + @Test + fun `refresh maps actionable bitcoin request`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + + sut.refresh().getOrThrow() + + val request = sut.pendingRequests.value.single() + assertEquals(100_000uL, request.amountSats) + assertEquals("invoice-123", request.paymentReference) + assertEquals("""{"order":"123"}""", request.metadata) + assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) + } + + @Test + fun `refresh drops expired unsupported and non payer requests`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + listOf( + paymentRequestRecord(expiresAt = clock.now().toString()), + paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), + paymentRequestRecord(id = "payee", role = PaymentRequestLocalRole.PAYEE), + ), + ) + + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `pending request is removed exactly when it expires`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), + ) + sut.refresh().getOrThrow() + + advanceTimeBy(9_999) + runCurrent() + assertEquals(1, sut.pendingRequests.value.size) + + advanceTimeBy(1) + runCurrent() + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `accept removes current request and delivers queued response`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ), + ).thenReturn(record) + sut.refresh().getOrThrow() + clearInvocations(paykitSdkService) + + sut.accept(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + } + + @Test + fun `expired request cannot be accepted`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + advanceTimeBy(1_000) + + assertFailsWith { + sut.accept(request).getOrThrow() + } + verifyBlocking(paykitSdkService, never()) { + acceptPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + } + + @Suppress("LongParameterList") + private fun paymentRequestRecord( + id: String = PAYMENT_REQUEST_ID, + role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYER, + amount: String = "0.001", + expiresAt: String? = null, + endpoints: List = listOf(MethodId.Bolt11.rawValue), + ) = PaymentRequestRecord( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + paymentRequestId = id, + localRole = role, + state = PaymentRequestLifecycleState.PROPOSED, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "proposal-event", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = amount, asset = "btc"), + paymentReference = PAYMENT_REFERENCE, + proposalExpiresAt = expiresAt, + recurrence = null, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = METADATA, + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = emptyList(), + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = clock.now().toString(), + invalidReason = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 9970ce3784..60f3ded298 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4,16 +4,17 @@ import android.app.Activity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.ContactRecord import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState -import com.synonym.paykit.PaymentEndpointSource +import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput import com.synonym.paykit.PrivatePaymentListSyncChange +import com.synonym.paykit.PrivatePaymentResolutionState +import com.synonym.paykit.PrivatePaymentResolutionStatus import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -36,6 +37,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -43,11 +45,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.PaykitPreparedPrivateContactPayment +import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitPrivateReceiverPathSelection import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService @@ -62,6 +66,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +@Suppress("LargeClass") class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -382,6 +387,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( @@ -607,6 +632,51 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `cleanup falls back per contact when shared linked receiver inspection fails`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + var linkedPeerReads = 0 + whenever { paykitSdkService.linkedPeers() }.thenAnswer { + linkedPeerReads += 1 + if (linkedPeerReads <= 2) error("link inspection failed") + emptyList() + } + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `invalid deleted contact key remains pending for cleanup`() = test { + val invalidPublicKey = "not-a-pubky" + cacheData.value = PrivatePaykitCacheData( + deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isFailure) + assertEquals(setOf(invalidPublicKey), cacheData.value.deletedContactCleanupPendingPublicKeys) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } } @@ -629,6 +699,64 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } } + @Test + fun `cleanup drains all contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() }.thenReturn( + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } + verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } + verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + } + + @Test + fun `cleanup isolates a failed contact while clearing successful contacts`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + privateListDeliveryReport( + failedToQueue = listOf( + PrivatePaymentListSyncChange( + counterparty = CONTACT_KEY, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, + outboundMessageId = null, + error = "failed", + ), + ), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `prepareSavedContacts records queued contacts when another contact cannot publish`() = test { settingsData.value = SettingsData( @@ -705,360 +833,242 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `beginSavedContactPayment uses public SDK endpoint when live session is unavailable`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - whenever(paykitSdkService.identityStatus()).thenReturn( - IdentityStatus( - publicKey = OWN_KEY, - liveSessionAvailable = 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 = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + status = PrivatePaymentResolutionStatus.NO_ENDPOINT, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKING, + version = null, ), ) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) - } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } } @Test - fun `beginSavedContactPayment opens SDK resolved private endpoint`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment uses public resolution without live Noise session`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + liveSessionAvailable = false, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened(PRIVATE_BOLT11), result) - verifyBlocking(paykitSdkService) { - prepareAndResolveContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, includePublicEndpoints = true) + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } + verifyBlocking(paykitSdkService, never()) { + prepareAndResolvePrivateContactPayment(any(), any(), any(), any()) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment refreshes private endpoints before unified resolution`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment opens private endpoint with its list version`() = test { 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 = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - ), - ) + 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("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) - } + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL), + ), + result, + ) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment does not fall back to public when unified resolution is cancelled`() = 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, - ) - }.thenThrow(CancellationException("cancelled")) + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn( + resolution( + status = PrivatePaymentResolutionStatus.NO_ENDPOINT, + state = PrivatePaymentResolutionState.RECOVERY_PENDING, + linkState = LinkedPeerState.RECOVERY_REQUIRED, + version = null, + ), + ) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) 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) + fun `beginSavedContactPayment waits for newer private list without public fallback`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } - .thenThrow(CancellationException("cancelled")) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn( + resolution( + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKED, + version = null, + ), + ) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } + assertEquals(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, result) 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) + fun `beginSavedContactPayment only falls back after failure when Noise link is absent`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever { walletRepo.refreshReusableReceiveAddressIfReserved() } - .thenThrow(CancellationException("cancelled")) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenThrow(IllegalStateException("private unavailable")) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) - } + val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + assertEquals(PublicPaykitPaymentResult.Opened("bitcoin:bcrt1qpublic"), result) + verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } } @Test - fun `beginSavedContactPayment does not fall back to public when publish gate is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment propagates failure when Noise link exists`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) - clearInvocations(paykitSdkService, publicPaykitRepo) - whenever(pubkyService.currentPublicKey()).thenThrow(CancellationException("cancelled")) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenThrow(IllegalStateException("private unavailable")) + whenever(paykitSdkService.linkedPeers()) + .thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + assertFailsWith { + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() } - - verifyBlocking(paykitSdkService, never()) { prepareAndResolveContactPayment(any(), any(), any()) } verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment falls back to public when unified resolution fails`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenThrow(IllegalStateException("private unavailable")) - whenever(publicPaykitRepo.beginPayment(CONTACT_KEY)).thenReturn( - Result.success(PublicPaykitPaymentResult.Opened("public-fallback")), - ) + fun `consumePrivatePaymentList persists version clears list and rejects reuse`() = test { + val context = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - - assertEquals(PublicPaykitPaymentResult.Opened("public-fallback"), result) - verifyBlocking(publicPaykitRepo) { beginPayment(CONTACT_KEY) } - } + sut.consumePrivatePaymentList(CONTACT_KEY, context).getOrThrow() - @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 = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - ), + assertEquals( + 7uL, + cacheData.value.contacts.getValue(CONTACT_KEY) + .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], ) - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + 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 = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), - privateState = ContactPaymentResolutionPrivateState.RECOVERY_PENDING, + status = PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST, + state = PrivatePaymentResolutionState.NO_PRIVATE_ENDPOINT, + linkState = LinkedPeerState.LINKED, + version = null, ), ) - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + verifyBlocking(paykitSdkService) { + prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) + } } @Test - fun `beginSavedContactPayment returns no endpoint when recovery pending has no public endpoint`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) + fun `beginSavedContactPayment does not fall back when private resolution is cancelled`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution(privateState = ContactPaymentResolutionPrivateState.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 = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + resolvedEndpoint(MethodId.P2wpkh, PRIVATE_ADDRESS), + resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), + version = 7uL, ), ) - whenever { publicPaykitRepo.payableEndpoints(any()) }.thenAnswer { - val endpoints = it.getArgument>(0) - val hasLightningEndpoint = endpoints.any { endpoint -> endpoint.methodId == MethodId.Bolt11 } - endpoints.takeUnless { hasLightningEndpoint }.orEmpty() - } - - val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) - assertEquals(PublicPaykitPaymentResult.Opened("bcrt1qpublic"), result) - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } - } + val result = sut.beginPaymentRequest(request).getOrThrow() - @Test - fun `beginSavedContactPayment does not fall back to public when private payable check is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = PRIVATE_ADDRESS, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), ), + result, ) - whenever { coreService.isAddressUsed(PRIVATE_ADDRESS) } - .thenThrow(CancellationException("cancelled")) - - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + val amountCaptor = argumentCaptor() + verifyBlocking(paykitSdkService) { + prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + amountCaptor.capture(), + ) } - + assertEquals("0.000025", amountCaptor.firstValue.value) + assertEquals("btc", amountCaptor.firstValue.asset) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @Test - fun `beginSavedContactPayment does not fall back to public when private invoice decode is cancelled`() = test { - settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) - sut.prepareSavedContacts(listOf(CONTACT_KEY)) - whenever { - paykitSdkService.prepareAndResolveContactPayment( - CONTACT_KEY, - WALLET_RECEIVER_PATH, - includePublicEndpoints = true, - ) - }.thenReturn( - resolution( - resolvedEndpoint( - methodId = MethodId.Bolt11, - value = PRIVATE_BOLT11, - ), - resolvedEndpoint( - methodId = MethodId.P2wpkh, - value = "bcrt1qpublic", - source = PaymentEndpointSource.PUBLIC_PAYMENT_ENDPOINT, - ), + fun `beginPaymentRequest never falls back to public resolution without live Noise session`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn( + IdentityStatus( + publicKey = OWN_KEY, + liveSessionAvailable = false, ), ) - whenever(coreService.decode(PRIVATE_BOLT11)) - .thenThrow(CancellationException("cancelled")) - assertFailsWith { - sut.beginSavedContactPayment(CONTACT_KEY) + assertFailsWith { + sut.beginPaymentRequest(paymentRequest()).getOrThrow() } - verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1066,11 +1076,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() - assertEquals(backup, snapshot) + assertTrue(snapshot?.contains(backup) == true) + assertEquals( + 7uL, + cacheData.value.contacts.getValue(CONTACT_KEY) + .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + ) verifyBlocking(paykitSdkService) { restoreBackupState(backup) } } @@ -1090,25 +1109,52 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun resolution( vararg endpoints: PaykitResolvedPaymentEndpoint, - privateState: ContactPaymentResolutionPrivateState = ContactPaymentResolutionPrivateState.AVAILABLE, - ) = PaykitContactPaymentResolution( - privateState = privateState, - payableEndpoints = endpoints.toList(), + 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: PaymentEndpointSource = PaymentEndpointSource.PRIVATE_PAYMENT_LIST, ): PaykitResolvedPaymentEndpoint { return PaykitResolvedPaymentEndpoint( - counterparty = CONTACT_KEY, - source = source, identifier = methodId.rawValue, payload = PublicPaykitRepo.serializePayload(value), ) } + private fun paymentRequest( + acceptedEndpointIdentifiers: List = listOf(MethodId.Bolt11.rawValue), + ) = PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = CONTACT_KEY, + counterpartyReceiverPath = SERVER_RECEIVER_PATH, + amountValue = "0.000025", + amountSats = 2_500uL, + paymentReference = "reference", + expiresAt = Instant.fromEpochSeconds(NOW_SECONDS + 60), + acceptedPaymentEndpointIdentifiers = acceptedEndpointIdentifiers, + metadata = "", + ) + private fun privateListDeliveryReport( queuedCounterparties: List = emptyList(), clearedCounterparties: List = emptyList(), @@ -1134,6 +1180,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 db262bba9b..612a5d8205 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -3,10 +3,8 @@ package to.bitkit.repositories import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner -import com.synonym.paykit.ContactPaymentResolutionPrivateState import com.synonym.paykit.EndpointSyncChange import com.synonym.paykit.EndpointSyncReport -import com.synonym.paykit.PaymentEndpointSource import com.synonym.paykit.PublicationStatus import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After @@ -21,7 +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.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 = ContactPaymentResolutionPrivateState.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 = PaymentEndpointSource.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 20067b502f..30af427da8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -28,6 +30,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations 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 @@ -57,12 +60,17 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestError +import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo @@ -90,8 +98,10 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @Suppress("LargeClass") @@ -125,6 +135,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() @@ -143,6 +154,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() @@ -187,6 +199,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) + whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } @@ -259,6 +272,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { nodeServiceFgState = nodeServiceFgState, publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, + paykitPaymentRequestRepo = paykitPaymentRequestRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -285,6 +299,83 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onAppForegrounded() } + @Test + fun `payment requests refresh periodically only while polling is active`() = test { + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo).refresh() + + sut.stopPaykitPaymentRequestPolling() + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + verify(paykitPaymentRequestRepo, never()).refresh() + } + + @Test + fun `payment request waiting for a newer private list is retried on refresh`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1updatedpaymentrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.beginPaymentRequest(request) }.thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + assertNull(sut.currentSheet.value) + + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `active contact payment prevents presenting another payment request`() = test { + val activeRequest = paymentRequest() + val pendingRequest = activeRequest.copy(paymentRequestId = "next-request") + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = activeRequest) + pendingPaykitPaymentRequests.value = listOf(pendingRequest) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) + runCurrent() + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + verify(privatePaykitRepo, never()).beginPaymentRequest(pendingRequest) + assertEquals(activeRequest, activeContactPaymentContext()?.incomingPaymentRequest) + } + @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" @@ -1018,6 +1109,59 @@ 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 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") @@ -1042,9 +1186,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( @@ -1056,7 +1201,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, @@ -1068,7 +1215,155 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).discardRemoteOnchainEndpoints(contactKey, setOf(address)) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) + } + + @Test + fun `incoming payment request is accepted before its private list is consumed`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { paykitPaymentRequestRepo.accept(request) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext) } + .thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + } + + @Test + fun `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.accept(request) } + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) } @Test @@ -1100,13 +1395,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private lightning contact payment discards remote invoice after 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, @@ -1128,18 +1426,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test - fun `private lightning pending payment discards remote 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, @@ -1152,17 +1453,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf(paymentHash)) + 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, @@ -1175,7 +1479,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(privatePaykitRepo).discardRemoteLightningEndpoints(contactKey, setOf("010203")) + verify(privatePaykitRepo).consumePrivatePaymentList(contactKey, privateContext) } @Test @@ -1484,10 +1788,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contexts[paymentHash] = ContactPaymentContext(publicKey) } - private fun setActiveContactPaymentContext(publicKey: String) { + private fun setActiveContactPaymentContext( + publicKey: String, + privatePaymentContext: PrivatePaykitPaymentContext? = null, + incomingPaymentRequest: PaykitPaymentRequest? = null, + ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true - field.set(sut, ContactPaymentContext(publicKey)) + field.set(sut, ContactPaymentContext(publicKey, privatePaymentContext, incomingPaymentRequest)) } private fun activeContactPaymentContext(): ContactPaymentContext? { @@ -1534,6 +1842,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { method.isAccessible = true method.invoke(sut) } + + private fun paymentRequest() = PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = testPublicKey, + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.000025", + amountSats = 2_500uL, + paymentReference = "reference", + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), + metadata = "", + ) } private const val SAMROCK_SETUP_URL = diff --git a/changelog.d/next/1098.added.md b/changelog.d/next/1098.added.md new file mode 100644 index 0000000000..94f414e756 --- /dev/null +++ b/changelog.d/next/1098.added.md @@ -0,0 +1 @@ +Incoming Paykit payment requests can now be reviewed and approved through the existing payment flow. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 036f25963d..7981a7d60e 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.4.2" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc37" } +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" }