diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index ab88c2cd7..fefa72156 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -234,6 +234,7 @@ class Keychain @Inject constructor( PAYKIT_SESSION, PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, + PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt new file mode 100644 index 000000000..cf62ca5c1 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -0,0 +1,375 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.fromHex +import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.toHex +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.Logger +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +enum class PaykitPaymentProofKind(val type: String) { + Lightning("bitcoin-bolt11-preimage"), + Onchain("bitcoin-onchain-txid"), +} + +@Serializable +data class PendingPaykitPaymentProof( + val identity: String, + val requestId: PaykitPaymentRequestId, + val paymentEndpointIdentifier: String, + val kind: PaykitPaymentProofKind, + val paymentIdentifier: String? = null, + val proofData: String? = null, +) + +@Singleton +class PaykitPaymentProofRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val paykitSdkService: PaykitSdkService, + private val lightningRepo: LightningRepo, + private val store: PaykitPaymentProofStore, +) { + companion object { + private const val TAG = "PaykitPaymentProofRepo" + private const val HASH_BYTE_COUNT = 32 + } + + private val operationMutex = Mutex() + + suspend fun prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val proof = pendingProof(request, paymentEndpointIdentifier, kind) + val proofs = loadProofs() + .filterNot { + PubkyPublicKeyFormat.matches(it.identity, proof.identity) && + it.requestId == request.id && + it.paymentIdentifier == null && + it.proofData == null + } + + proof + persist(proofs) + } + }.onFailure { Logger.warn("Failed to prepare a Paykit payment proof", it, context = TAG) } + } + + suspend fun associateLightningPayment(request: PaykitPaymentRequest, paymentHash: String): Result = + withContext(ioDispatcher) { + runSuspendCatching { + if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier == null && + it.proofData == null + } + if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable + proofs[index] = proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) + persist(proofs) + } + }.onFailure { Logger.warn("Failed to associate a Paykit Lightning payment proof", it, context = TAG) } + } + + suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) { + if (preimage == null) return@withContext + if (!preimage.matchesPaymentHash(paymentHash)) { + Logger.warn("Ignored a Paykit Lightning proof whose preimage did not match its payment hash", context = TAG) + return@withContext + } + + operationMutex.withLock { + runSuspendCatching { + val currentProofs = loadProofs() + val matchingProofs = currentProofs.filter { + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + } + if (matchingProofs.isEmpty()) return@runSuspendCatching + val proofs = currentProofs.map { + if ( + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + ) { + it.copy(proofData = preimage.lowercase()) + } else { + it + } + } + val completedProofs = proofs.filter { + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + } + persistAndSubmit(completedProofs, proofs) + }.onFailure { Logger.warn("Failed to complete a Paykit Lightning payment proof", it, context = TAG) } + } + } + + suspend fun completeOnchainPayment( + request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String, + ) = withContext(ioDispatcher) { + if (!txid.isHex(HASH_BYTE_COUNT)) { + Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) + return@withContext + } + + operationMutex.withLock { + val completion = runSuspendCatching { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentIdentifier == null && + it.proofData == null + } + if (index < 0) return@runSuspendCatching + val proof = proofs[index].copy( + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + proofs[index] = proof + persistAndSubmit(listOf(proof), proofs) + } + completion.onFailure { + Logger.warn( + "Failed to load a Paykit on-chain payment proof; attempting immediate delivery", + it, + context = TAG, + ) + } + if (completion.isFailure) { + runSuspendCatching { + val proof = pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + submitReady(proof) + }.onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + } + } + } + + suspend fun failLightningPayment(paymentHash: String) = removeProofs { + it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + } + + suspend fun cancelPreparation(request: PaykitPaymentRequest) = removeProofs { + it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null + } + + suspend fun reconcile() = withContext(ioDispatcher) { + operationMutex.withLock { + runSuspendCatching { + val identityStatus = paykitSdkService.identityStatus() + if (identityStatus?.liveSessionAvailable != true) return@runSuspendCatching + val publicKey = identityStatus.publicKey ?: return@runSuspendCatching + val identity = PubkyPublicKeyFormat.normalized(publicKey) ?: return@runSuspendCatching + val proofs = loadProofs().filter { PubkyPublicKeyFormat.matches(it.identity, identity) } + val payments = if (proofs.any { it.kind == PaykitPaymentProofKind.Lightning && it.proofData == null }) { + lightningRepo.getPayments().getOrDefault(emptyList()) + } else { + emptyList() + } + + proofs.forEach { reconcileProof(it, payments) } + }.onFailure { Logger.warn("Failed to reconcile pending Paykit payment proofs", it, context = TAG) } + } + } + + private suspend fun reconcileProof( + proof: PendingPaykitPaymentProof, + payments: List, + ) { + if (proof.proofData != null) { + submitReady(proof) + return + } + val paymentHash = proof.paymentIdentifier + if (proof.kind != PaykitPaymentProofKind.Lightning || paymentHash == null) return + val payment = payments.firstOrNull { + it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) + } ?: return + when (payment.status) { + PaymentStatus.PENDING -> Unit + PaymentStatus.FAILED -> removeProofsLocked { + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + } + PaymentStatus.SUCCEEDED -> { + val preimage = (payment.kind as? PaymentKind.Bolt11)?.preimage + if (preimage != null && preimage.matchesPaymentHash(paymentHash)) { + val completed = proof.copy(proofData = preimage.lowercase()) + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index >= 0) { + proofs[index] = completed + persistAndSubmit(listOf(completed), proofs) + } + } + } + } + } + + private suspend fun submitReady(proof: PendingPaykitPaymentProof) { + val proofData = proof.proofData ?: return + val identityStatus = paykitSdkService.identityStatus() + if ( + identityStatus?.liveSessionAvailable != true || + !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) + ) { + return + } + + val record = paykitSdkService.paymentRequests().firstOrNull { + it.paymentRequestId == proof.requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && + it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath + } ?: return + val proofJson = proofJson(proof.kind, proofData) + val alreadyQueued = record.paymentProofs.any { + it.billingPeriod == null && + it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && + it.proof.exportText().proofValues() == proofJson.proofValues() + } + if (!alreadyQueued) { + paykitSdkService.submitPaymentProof( + counterparty = proof.requestId.counterparty, + counterpartyReceiverPath = proof.requestId.counterpartyReceiverPath, + paymentRequestId = proof.requestId.paymentRequestId, + paymentEndpointIdentifier = proof.paymentEndpointIdentifier, + proofJson = proofJson, + ) + Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) + runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + .onFailure { + Logger.warn( + "Paykit payment proof remains queued for private delivery", + it, + context = TAG, + ) + } + } + removeProofsLocked { + PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId + } + } + + private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { + operationMutex.withLock { + runSuspendCatching { removeProofsLocked(predicate) } + .onFailure { Logger.warn("Failed to clear a pending Paykit payment proof", it, context = TAG) } + } + } + + private suspend fun removeProofsLocked(predicate: (PendingPaykitPaymentProof) -> Boolean) { + val current = loadProofs() + val remaining = current.filterNot(predicate) + if (remaining != current) persist(remaining) + } + + private suspend fun persistAndSubmit( + completedProofs: List, + allProofs: List, + ) { + runSuspendCatching { persist(allProofs) } + .onFailure { + Logger.warn( + "Failed to persist a completed Paykit payment proof; attempting immediate delivery", + it, + context = TAG, + ) + } + completedProofs.forEach { submitReady(it) } + } + + private suspend fun pendingProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + ): PendingPaykitPaymentProof { + if ( + paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || + !endpointSupports(paymentEndpointIdentifier, kind) + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } + val identityStatus = paykitSdkService.identityStatus() + val identity = identityStatus?.publicKey?.let { PubkyPublicKeyFormat.normalized(it) } + if (identityStatus?.liveSessionAvailable != true || identity == null) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return PendingPaykitPaymentProof( + identity = identity, + requestId = request.id, + paymentEndpointIdentifier = paymentEndpointIdentifier, + kind = kind, + ) + } + + private fun loadProofs(): List = store.load() + + private suspend fun persist(proofs: List) { + store.save(proofs) + } +} + +private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): Boolean { + val method = MethodId.fromRawValue(identifier) ?: return false + return when (kind) { + PaykitPaymentProofKind.Lightning -> method == MethodId.Bolt11 || method == MethodId.Lnurl + PaykitPaymentProofKind.Onchain -> method.isOnchain + } +} + +private fun proofJson(kind: PaykitPaymentProofKind, data: String): String = buildJsonObject { + put("data", JsonPrimitive(data)) + put("type", JsonPrimitive(kind.type)) +}.toString() + +private fun String.proofValues(): JsonObject? = runCatching { + Json.parseToJsonElement(this).jsonObject.let { values -> + buildJsonObject { + values["data"]?.jsonPrimitive?.contentOrNull?.let { put("data", JsonPrimitive(it)) } + values["type"]?.jsonPrimitive?.contentOrNull?.let { put("type", JsonPrimitive(it)) } + } + } +}.getOrNull() + +private fun String.matchesPaymentHash(paymentHash: String): Boolean { + val preimage = hexBytes() ?: return false + if (preimage.size != 32) return false + val hash = MessageDigest.getInstance("SHA-256").digest(preimage).toHex() + return hash.equals(paymentHash, ignoreCase = true) +} + +private fun String.isHex(byteCount: Int): Boolean = hexBytes()?.size == byteCount + +private fun String.hexBytes(): ByteArray? = runCatching { fromHex() }.getOrNull() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt new file mode 100644 index 000000000..e03a7fe19 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -0,0 +1,31 @@ +package to.bitkit.repositories + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.data.keychain.Keychain +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PaykitPaymentProofStore @Inject constructor( + private val keychain: Keychain, +) { + @Serializable + private data class State( + val proofs: List = emptyList(), + ) + + fun load(): List { + val value = keychain.loadString(Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name) ?: return emptyList() + return Json.decodeFromString(value).proofs + } + + suspend fun save(proofs: List) { + keychain.upsertString( + Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, + Json.encodeToString(State(proofs)), + ) + } +} diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c54e3ac4a..fcf69c9e7 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -21,6 +21,7 @@ import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PaymentPayload +import com.synonym.paykit.PaymentProofSubmission import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter @@ -654,6 +655,30 @@ class PaykitSdkService @Inject constructor( } } + suspend fun submitPaymentProof( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + paymentEndpointIdentifier: String, + proofJson: String, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.submitPaymentProof( + counterparty, + counterpartyReceiverPath, + paymentRequestId, + PaymentProofSubmission( + billingPeriod = null, + paymentEndpointIdentifier = paymentEndpointIdentifier, + proof = PrivateJsonObject(proofJson), + ), + ) + } + } + } + suspend fun rejectPaymentRequest( counterparty: String, counterpartyReceiverPath: String, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0fafc73ec..0b234827f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -141,7 +141,10 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError +import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDraft @@ -231,6 +234,7 @@ class AppViewModel @Inject constructor( private val publicPaykitRepo: PublicPaykitRepo, private val privatePaykitRepo: PrivatePaykitRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, + private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -687,6 +691,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false + paykitPaymentProofRepo.reconcile() val previousRequests = paykitPaymentRequestRepo.pendingRequests.value val savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey } return paykitPaymentRequestRepo.refresh(savedPublicKeys).fold( @@ -1300,6 +1305,9 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { + (event.paymentHash ?: event.paymentId)?.let { paymentHash -> + viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) } + } event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -1376,6 +1384,9 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { + viewModelScope.launch { + paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) + } event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -2924,18 +2935,29 @@ class AppViewModel @Inject constructor( } } - @Suppress("LongMethod") + @Suppress("LongMethod", "ReturnCount") private suspend fun proceedWithPayment(contactPaymentContext: ContactPaymentContext?) { delay(SCREEN_TRANSITION_DELAY) // wait for screen transitions when applicable if (!validateIncomingPaymentRequest(contactPaymentContext)) return + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( + onSuccess = { it }, + onFailure = { + handlePaymentPreparationFailure(it) + return + }, + ) + consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } @@ -2956,6 +2978,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -2969,6 +2992,8 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags sendOnchain(address, amount, tags = tags) .onSuccess { txId -> + preparedPaymentProofRequest = null + completeOnchainPaymentProof(incomingPaymentRequest, txId) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -2983,6 +3008,7 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() _successSendUiState.update { it.copy(isLoadingDetails = false) } }.onFailure { e -> + cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", e, context = TAG) toast( type = Toast.ToastType.ERROR, @@ -3005,6 +3031,11 @@ class AppViewModel @Inject constructor( // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() + associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it) + return + } // Create pre-activity metadata before sending if (tags.isNotEmpty()) { @@ -3020,6 +3051,7 @@ class AppViewModel @Inject constructor( } sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> + preparedPaymentProofRequest = null Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3031,12 +3063,15 @@ class AppViewModel @Inject constructor( ) }.onFailure { if (it is PaymentPendingException) { + preparedPaymentProofRequest = null Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) preserveContactPaymentContext(it.paymentHash) setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } + paykitPaymentProofRepo.failLightningPayment(paymentHash) + cancelPaymentProofPreparation(preparedPaymentProofRequest) // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -3054,6 +3089,51 @@ class AppViewModel @Inject constructor( } } + private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { + if (request == null) return Result.success(null) + val preparation = paymentProofPreparation() + return paykitPaymentProofRepo.prepare( + request = request, + paymentEndpointIdentifier = preparation.endpointIdentifier, + kind = preparation.kind, + ).map { request } + } + + private suspend fun associateLightningPaymentProof( + request: PaykitPaymentRequest?, + paymentHash: String, + ): Result = request?.let { paykitPaymentProofRepo.associateLightningPayment(it, paymentHash) } + ?: Result.success(Unit) + + private suspend fun completeOnchainPaymentProof(request: PaykitPaymentRequest?, txId: String) { + request?.let { + paykitPaymentProofRepo.completeOnchainPayment( + request = it, + txid = txId, + paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, + ) + } + } + + private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { + request?.let { paykitPaymentProofRepo.cancelPreparation(it) } + } + + private fun paymentProofPreparation(): PaymentProofPreparation { + val methodId = when (_sendUiState.value.payMethod) { + SendMethod.ONCHAIN -> PublicPaykitRepo.onchainMethodId(_sendUiState.value.address) + SendMethod.LIGHTNING -> if (_sendUiState.value.lnurl is LnurlParams.LnurlPay) { + MethodId.Lnurl + } else { + MethodId.Bolt11 + } + } + return PaymentProofPreparation( + endpointIdentifier = methodId.rawValue, + kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning, + ) + } + private suspend fun hasMismatchedIncomingPaymentRequest(contactPaymentContext: ContactPaymentContext?): Boolean { val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest ?: return false if (!incomingPaymentRequest.acceptsPaymentAmount(_sendUiState.value.amount)) return true @@ -4244,6 +4324,11 @@ data class ContactPaymentContext( val incomingPaymentRequest: PaykitPaymentRequest? = null, ) +private data class PaymentProofPreparation( + val endpointIdentifier: String, + val kind: PaykitPaymentProofKind, +) + private data class PaykitContactSyncState( val publicKey: String?, val contactKeys: Set, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt new file mode 100644 index 000000000..bace95369 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -0,0 +1,361 @@ +package to.bitkit.repositories + +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.PaymentProofRecord +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.test.StandardTestDispatcher +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.PaymentDetails +import org.lightningdevkit.ldknode.PaymentDirection +import org.lightningdevkit.ldknode.PaymentKind +import org.lightningdevkit.ldknode.PaymentStatus +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { + companion object { + private const val LOCAL_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" + private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" + private val PREIMAGE = "00".repeat(32) + } + + private val paykitSdkService = mock() + private val lightningRepo = mock() + private val store = mock() + private var storedProofs = emptyList() + private var shouldFailNextLoad = false + private var shouldFailNextSave = false + + @Before + fun setUp() = test { + storedProofs = emptyList() + shouldFailNextLoad = false + shouldFailNextSave = false + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(store.load()).thenAnswer { + if (shouldFailNextLoad) { + shouldFailNextLoad = false + error("temporary load failure") + } + storedProofs + } + whenever(store.save(any())).doSuspendableAnswer { + if (shouldFailNextSave) { + shouldFailNextSave = false + error("temporary save failure") + } + storedProofs = it.getArgument(0) + } + } + + @Test + fun `completed lightning proof retries after repository restart`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())) + .thenThrow(IllegalStateException("temporary failure")) + .thenReturn(record) + val firstRepo = paymentProofRepo() + + firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + assertEquals(PREIMAGE, storedProofs.single().proofData) + + paymentProofRepo().reconcile() + + val endpointCaptor = argumentCaptor() + val proofCaptor = argumentCaptor() + verify(paykitSdkService, times(2)).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = endpointCaptor.capture(), + proofJson = proofCaptor.capture(), + ) + assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) + assertEquals( + """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", + proofCaptor.lastValue, + ) + assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService).processPendingPrivateMessages() + } + + @Test + fun `associated lightning proof completes after repository restart`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + val paymentKind = mock { + on { preimage } doReturn PREIMAGE + } + val payment = mock { + on { id } doReturn PAYMENT_HASH + on { kind } doReturn paymentKind + on { direction } doReturn PaymentDirection.OUTBOUND + on { status } doReturn PaymentStatus.SUCCEEDED + } + whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val firstRepo = paymentProofRepo() + + firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + assertNull(storedProofs.single().proofData) + + paymentProofRepo().reconcile() + + verify(lightningRepo).getPayments() + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = any(), + proofJson = proofCaptor.capture(), + ) + assertEquals( + """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", + proofCaptor.firstValue, + ) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `mismatched lightning preimage is not submitted`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) + + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + } + + @Test + fun `existing proof suppresses duplicate submission`() = test { + val existingProofJson = mock { + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + } + val existingProof = mock { + on { billingPeriod } doReturn null + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { proof } doReturn existingProofJson + } + val record = paymentRequestRecord(listOf(existingProof)) + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + } + + @Test + fun `failed lightning payment clears persisted correlation`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.failLightningPayment(PAYMENT_HASH) + + assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + } + + @Test + fun `onchain proof uses selected endpoint and transaction id`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + + val endpointCaptor = argumentCaptor() + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + any(), + any(), + any(), + endpointCaptor.capture(), + proofCaptor.capture(), + ) + assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) + assertEquals( + """{"data":"$txid","type":"${PaykitPaymentProofKind.Onchain.type}"}""", + proofCaptor.firstValue, + ) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `lightning retry preserves earlier payment correlation`() = test { + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, "aa".repeat(32)).getOrThrow() + + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `cleared store does not restore cached proofs`() = test { + val firstRequest = paymentRequest(MethodId.Bolt11.rawValue) + val secondRequestId = "550e8400-e29b-41d4-a716-446655440001" + val secondRequest = paymentRequest(MethodId.Bolt11.rawValue, secondRequestId) + val repo = paymentProofRepo() + + repo.prepare(firstRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + storedProofs = emptyList() + repo.prepare(secondRequest, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + + assertEquals(1, storedProofs.size) + assertEquals(secondRequestId, storedProofs.single().requestId.paymentRequestId) + } + + @Test + fun `onchain proof submits when completed proof cannot be persisted`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + shouldFailNextSave = true + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `onchain proof submits when prepared proof cannot be loaded`() = test { + val txid = "ab".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + shouldFailNextLoad = true + repo.completeOnchainPayment(request, txid, endpoint) + + val endpointCaptor = argumentCaptor() + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = endpointCaptor.capture(), + proofJson = proofCaptor.capture(), + ) + assertEquals(endpoint, endpointCaptor.firstValue) + assertEquals( + """{"data":"$txid","type":"${PaykitPaymentProofKind.Onchain.type}"}""", + proofCaptor.firstValue, + ) + assertTrue(storedProofs.isEmpty()) + } + + private fun paymentProofRepo() = PaykitPaymentProofRepo( + ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, + lightningRepo = lightningRepo, + store = store, + ) + + private fun paymentRequest( + endpoint: String, + paymentRequestId: String = PAYMENT_REQUEST_ID, + ) = PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.WALLET, + amountValue = "0.00001", + amountSats = 1_000uL, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf(endpoint), + ) + + private fun paymentRequestRecord(paymentProofs: List = emptyList()) = PaymentRequestRecord( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.WALLET, + paymentRequestId = PAYMENT_REQUEST_ID, + localRole = PaymentRequestLocalRole.PAYER, + state = PaymentRequestLifecycleState.PROPOSED, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "proposal-event", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = "0.00001", asset = "btc"), + paymentReference = mock(), + proposalExpiresAt = null, + recurrence = null, + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + metadata = mock(), + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = paymentProofs, + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = "2027-01-15T08:00:00Z", + invalidReason = null, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 69d484580..2582d443f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -83,7 +83,10 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDraft @@ -170,6 +173,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val paykitPaymentRequestRepo = mock() + private val paykitPaymentProofRepo = mock() private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -265,6 +269,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) + whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any()) }.thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -352,6 +358,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitPaymentProofRepo = paykitPaymentProofRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -1947,6 +1954,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + verify(paykitPaymentProofRepo).completeLightningPayment(paymentHash, "preimage") } @Test @@ -1972,6 +1980,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) + verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -3057,10 +3066,94 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - inOrder(privatePaykitRepo, paykitPaymentRequestRepo).apply { + inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + } + + @Test + fun `pending incoming lightning payment keeps its proof association`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1pendingrequest" + val invoicePaymentHash = "010203" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + .doSuspendableAnswer { + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + Result.success(Unit) + } + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) + .thenReturn(Result.failure(PaymentPendingException("pending_hash"))) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = bolt11, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + isPaymentRequest = true, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { + verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).associateLightningPayment(request, invoicePaymentHash) + verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + } + verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `failed incoming lightning payment clears its proof association`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1failedrequest" + val invoicePaymentHash = "010203" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + .doSuspendableAnswer { + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + Result.success(Unit) + } + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)) + .thenReturn(Result.failure(IllegalStateException("send failed"))) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = bolt11, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + isPaymentRequest = true, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo, lightningRepo).apply { + verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).associateLightningPayment(request, invoicePaymentHash) + verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash) + verify(paykitPaymentProofRepo).cancelPreparation(request) + } } @Test @@ -3742,7 +3835,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountValue = "0.000025", amountSats = 2_500uL, expiresAt = null, - acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue, MethodId.P2wpkh.rawValue), ) private fun paymentRequestCreation( diff --git a/changelog.d/next/1178.added.md b/changelog.d/next/1178.added.md new file mode 100644 index 000000000..2a91491ee --- /dev/null +++ b/changelog.d/next/1178.added.md @@ -0,0 +1 @@ +Payments made from incoming private payment requests now send a payment proof back to the requester.