From a81071dd8a1b1b0390d62cb2af6ceb5f9b4877a0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:17:25 -0500 Subject: [PATCH 1/3] feat: send paykit payment proofs --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../repositories/PaykitPaymentProofRepo.kt | 308 ++++++++++++++++++ .../repositories/PaykitPaymentProofStore.kt | 37 +++ .../to/bitkit/services/PaykitSdkService.kt | 25 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 79 ++++- .../PaykitPaymentProofRepoTest.kt | 218 +++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 15 +- changelog.d/next/payment-proofs.added.md | 1 + 8 files changed, 681 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt create mode 100644 changelog.d/next/payment-proofs.added.md 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..40ad645d3 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -0,0 +1,308 @@ +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() + private var pendingProofs: List? = null + + suspend fun prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + require(paymentEndpointIdentifier in request.acceptedPaymentEndpointIdentifiers) + require(endpointSupports(paymentEndpointIdentifier, kind)) + val identityStatus = paykitSdkService.identityStatus() + check(identityStatus?.liveSessionAvailable == true) + val publicKey = checkNotNull(identityStatus.publicKey) + val identity = checkNotNull(PubkyPublicKeyFormat.normalized(publicKey)) + val proofs = loadProofs() + .filterNot { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id } + + PendingPaykitPaymentProof( + identity = identity, + requestId = request.id, + paymentEndpointIdentifier = paymentEndpointIdentifier, + kind = kind, + ) + 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 { + require(paymentHash.isHex(HASH_BYTE_COUNT)) + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfFirst { + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Lightning + } + check(index >= 0) + 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 matchingRequestIds = matchingProofs.map { it.requestId }.toSet() + val proofs = currentProofs.map { + if ( + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier.equals(paymentHash, ignoreCase = true) + ) { + it.copy(proofData = preimage.lowercase()) + } else { + it + } + } + persist(proofs) + proofs.filter { it.requestId in matchingRequestIds } + .forEach { submitReady(it) } + }.onFailure { Logger.warn("Failed to complete a Paykit Lightning payment proof", it, context = TAG) } + } + } + + suspend fun completeOnchainPayment(request: PaykitPaymentRequest, txid: 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 { + runSuspendCatching { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfFirst { + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Onchain + } + if (index < 0) return@runSuspendCatching + val proof = proofs[index].copy( + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + proofs[index] = proof + persist(proofs) + 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 cancel(request: PaykitPaymentRequest) = removeProofs { it.requestId == request.id } + + 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()) + replaceProof(completed) + submitReady(completed) + } + } + } + } + + 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) + } + removeProofsLocked { it == proof } + runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } + .onFailure { Logger.warn("Paykit payment proof remains queued for private delivery", it, context = TAG) } + } + + 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 replaceProof(proof: PendingPaykitPaymentProof) { + persist(loadProofs().map { if (it.requestId == proof.requestId) proof else it }) + } + + private fun loadProofs(): List = + pendingProofs ?: store.load().also { pendingProofs = it } + + private suspend fun persist(proofs: List) { + store.save(proofs) + pendingProofs = 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..30832183a --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -0,0 +1,37 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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, +) { + private val mutex = Mutex() + + @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) { + mutex.withLock { + 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..468611b5c 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,7 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { + (event.paymentHash ?: event.paymentId)?.let { paykitPaymentProofRepo.failLightningPayment(it) } event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -1376,6 +1382,7 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { + paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -2924,18 +2931,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 { + cancelPaymentProof(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + cancelPaymentProof(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } @@ -2956,6 +2974,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { + cancelPaymentProof(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -2969,6 +2988,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 +3004,7 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() _successSendUiState.update { it.copy(isLoadingDetails = false) } }.onFailure { e -> + cancelPaymentProof(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", e, context = TAG) toast( type = Toast.ToastType.ERROR, @@ -3005,6 +3027,11 @@ class AppViewModel @Inject constructor( // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() + associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { + cancelPaymentProof(preparedPaymentProofRequest) + handlePaymentPreparationFailure(it) + return + } // Create pre-activity metadata before sending if (tags.isNotEmpty()) { @@ -3020,6 +3047,8 @@ class AppViewModel @Inject constructor( } sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> + preparedPaymentProofRequest = null + paykitPaymentProofRepo.reconcile() Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3031,12 +3060,14 @@ 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 } + cancelPaymentProof(preparedPaymentProofRequest) // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -3054,6 +3085,47 @@ class AppViewModel @Inject constructor( } } + private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { + if (request == null) return Result.success(null) + val preparation = paymentProofPreparation(request) + ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + 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(it, txId) } + } + + private suspend fun cancelPaymentProof(request: PaykitPaymentRequest?) { + request?.let { paykitPaymentProofRepo.cancel(it) } + } + + private fun paymentProofPreparation(request: PaykitPaymentRequest): 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 + } + } + if (methodId.rawValue !in request.acceptedPaymentEndpointIdentifiers) return null + 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 +4316,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..877c57478 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -0,0 +1,218 @@ +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.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() + + @Before + fun setUp() = test { + storedProofs = emptyList() + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(store.load()).thenAnswer { storedProofs } + whenever(store.save(any())).doSuspendableAnswer { + 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 `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) + + 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()) + } + + private fun paymentProofRepo() = PaykitPaymentProofRepo( + ioDispatcher = testDispatcher, + paykitSdkService = paykitSdkService, + lightningRepo = lightningRepo, + store = store, + ) + + private fun paymentRequest(endpoint: String) = PaykitPaymentRequest( + paymentRequestId = PAYMENT_REQUEST_ID, + 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..e1adc18d0 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,12 @@ 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") } @Test @@ -3742,7 +3753,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/payment-proofs.added.md b/changelog.d/next/payment-proofs.added.md new file mode 100644 index 000000000..2a91491ee --- /dev/null +++ b/changelog.d/next/payment-proofs.added.md @@ -0,0 +1 @@ +Payments made from incoming private payment requests now send a payment proof back to the requester. From 3c5e85ad36faa3a10a0cab70c270271ef98232f9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:19:26 -0500 Subject: [PATCH 2/3] chore: rename changelog fragment --- changelog.d/next/{payment-proofs.added.md => 1178.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{payment-proofs.added.md => 1178.added.md} (100%) diff --git a/changelog.d/next/payment-proofs.added.md b/changelog.d/next/1178.added.md similarity index 100% rename from changelog.d/next/payment-proofs.added.md rename to changelog.d/next/1178.added.md From a6bd18866ea68781a6015455208dc7e68e18104e Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:42:28 -0500 Subject: [PATCH 3/3] fix: harden paykit proof delivery --- .../repositories/PaykitPaymentProofRepo.kt | 98 +++++++++++++------ .../repositories/PaykitPaymentProofStore.kt | 14 +-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 32 +++--- .../PaykitPaymentProofRepoTest.kt | 64 +++++++++++- 4 files changed, 151 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 40ad645d3..1cbcfe322 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -56,7 +56,6 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() - private var pendingProofs: List? = null suspend fun prepare( request: PaykitPaymentRequest, @@ -65,14 +64,24 @@ class PaykitPaymentProofRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - require(paymentEndpointIdentifier in request.acceptedPaymentEndpointIdentifiers) - require(endpointSupports(paymentEndpointIdentifier, kind)) + if ( + paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || + !endpointSupports(paymentEndpointIdentifier, kind) + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } val identityStatus = paykitSdkService.identityStatus() - check(identityStatus?.liveSessionAvailable == true) - val publicKey = checkNotNull(identityStatus.publicKey) - val identity = checkNotNull(PubkyPublicKeyFormat.normalized(publicKey)) + if (identityStatus?.liveSessionAvailable != true) throw PaykitPaymentRequestError.RequestUnavailable + val publicKey = identityStatus.publicKey ?: throw PaykitPaymentRequestError.RequestUnavailable + val identity = PubkyPublicKeyFormat.normalized(publicKey) + ?: throw PaykitPaymentRequestError.RequestUnavailable val proofs = loadProofs() - .filterNot { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id } + + .filterNot { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + it.paymentIdentifier == null && + it.proofData == null + } + PendingPaykitPaymentProof( identity = identity, requestId = request.id, @@ -87,13 +96,16 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun associateLightningPayment(request: PaykitPaymentRequest, paymentHash: String): Result = withContext(ioDispatcher) { runSuspendCatching { - require(paymentHash.isHex(HASH_BYTE_COUNT)) + if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable operationMutex.withLock { val proofs = loadProofs().toMutableList() - val index = proofs.indexOfFirst { - it.requestId == request.id && it.kind == PaykitPaymentProofKind.Lightning + val index = proofs.indexOfLast { + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier == null && + it.proofData == null } - check(index >= 0) + if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable proofs[index] = proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) persist(proofs) } @@ -115,7 +127,6 @@ class PaykitPaymentProofRepo @Inject constructor( it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } if (matchingProofs.isEmpty()) return@runSuspendCatching - val matchingRequestIds = matchingProofs.map { it.requestId }.toSet() val proofs = currentProofs.map { if ( it.kind == PaykitPaymentProofKind.Lightning && @@ -126,9 +137,11 @@ class PaykitPaymentProofRepo @Inject constructor( it } } - persist(proofs) - proofs.filter { it.requestId in matchingRequestIds } - .forEach { submitReady(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) } } } @@ -142,8 +155,11 @@ class PaykitPaymentProofRepo @Inject constructor( operationMutex.withLock { runSuspendCatching { val proofs = loadProofs().toMutableList() - val index = proofs.indexOfFirst { - it.requestId == request.id && it.kind == PaykitPaymentProofKind.Onchain + 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( @@ -151,8 +167,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofData = txid.lowercase(), ) proofs[index] = proof - persist(proofs) - submitReady(proof) + persistAndSubmit(listOf(proof), proofs) }.onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } } } @@ -161,7 +176,9 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } - suspend fun cancel(request: PaykitPaymentRequest) = removeProofs { it.requestId == request.id } + suspend fun cancelPreparation(request: PaykitPaymentRequest) = removeProofs { + it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null + } suspend fun reconcile() = withContext(ioDispatcher) { operationMutex.withLock { @@ -205,8 +222,12 @@ class PaykitPaymentProofRepo @Inject constructor( val preimage = (payment.kind as? PaymentKind.Bolt11)?.preimage if (preimage != null && preimage.matchesPaymentHash(paymentHash)) { val completed = proof.copy(proofData = preimage.lowercase()) - replaceProof(completed) - submitReady(completed) + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index >= 0) { + proofs[index] = completed + persistAndSubmit(listOf(completed), proofs) + } } } } @@ -242,10 +263,18 @@ class PaykitPaymentProofRepo @Inject constructor( 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 } - removeProofsLocked { it == proof } - runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } - .onFailure { Logger.warn("Paykit payment proof remains queued for private delivery", it, context = TAG) } } private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { @@ -261,16 +290,25 @@ class PaykitPaymentProofRepo @Inject constructor( if (remaining != current) persist(remaining) } - private suspend fun replaceProof(proof: PendingPaykitPaymentProof) { - persist(loadProofs().map { if (it.requestId == proof.requestId) proof else it }) + 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 fun loadProofs(): List = - pendingProofs ?: store.load().also { pendingProofs = it } + private fun loadProofs(): List = store.load() private suspend fun persist(proofs: List) { store.save(proofs) - pendingProofs = proofs } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index 30832183a..e03a7fe19 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -1,7 +1,5 @@ package to.bitkit.repositories -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -14,8 +12,6 @@ import javax.inject.Singleton class PaykitPaymentProofStore @Inject constructor( private val keychain: Keychain, ) { - private val mutex = Mutex() - @Serializable private data class State( val proofs: List = emptyList(), @@ -27,11 +23,9 @@ class PaykitPaymentProofStore @Inject constructor( } suspend fun save(proofs: List) { - mutex.withLock { - keychain.upsertString( - Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, - Json.encodeToString(State(proofs)), - ) - } + keychain.upsertString( + Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, + Json.encodeToString(State(proofs)), + ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 468611b5c..2bb0ede7c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1305,7 +1305,9 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { - (event.paymentHash ?: event.paymentId)?.let { paykitPaymentProofRepo.failLightningPayment(it) } + (event.paymentHash ?: event.paymentId)?.let { paymentHash -> + viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) } + } event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -1382,7 +1384,9 @@ class AppViewModel @Inject constructor( } private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { - paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) + viewModelScope.launch { + paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) + } event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { @@ -2947,13 +2951,13 @@ class AppViewModel @Inject constructor( ) consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } @@ -2974,7 +2978,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -3004,7 +3008,7 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() _successSendUiState.update { it.copy(isLoadingDetails = false) } }.onFailure { e -> - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", e, context = TAG) toast( type = Toast.ToastType.ERROR, @@ -3028,7 +3032,7 @@ class AppViewModel @Inject constructor( // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it) return } @@ -3048,7 +3052,6 @@ class AppViewModel @Inject constructor( sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> preparedPaymentProofRequest = null - paykitPaymentProofRepo.reconcile() Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3067,7 +3070,8 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } - cancelPaymentProof(preparedPaymentProofRequest) + paykitPaymentProofRepo.failLightningPayment(paymentHash) + cancelPaymentProofPreparation(preparedPaymentProofRequest) // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -3087,8 +3091,7 @@ class AppViewModel @Inject constructor( private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) - val preparation = paymentProofPreparation(request) - ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + val preparation = paymentProofPreparation() return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, @@ -3106,11 +3109,11 @@ class AppViewModel @Inject constructor( request?.let { paykitPaymentProofRepo.completeOnchainPayment(it, txId) } } - private suspend fun cancelPaymentProof(request: PaykitPaymentRequest?) { - request?.let { paykitPaymentProofRepo.cancel(it) } + private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { + request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } - private fun paymentProofPreparation(request: PaykitPaymentRequest): PaymentProofPreparation? { + 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) { @@ -3119,7 +3122,6 @@ class AppViewModel @Inject constructor( MethodId.Bolt11 } } - if (methodId.rawValue !in request.acceptedPaymentEndpointIdentifiers) return null return PaymentProofPreparation( endpointIdentifier = methodId.rawValue, kind = if (methodId.isOnchain) PaykitPaymentProofKind.Onchain else PaykitPaymentProofKind.Lightning, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 877c57478..320026e49 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -41,14 +41,20 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val lightningRepo = mock() private val store = mock() private var storedProofs = emptyList() + private var shouldFailNextSave = false @Before fun setUp() = test { storedProofs = emptyList() + shouldFailNextSave = false whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(store.load()).thenAnswer { storedProofs } whenever(store.save(any())).doSuspendableAnswer { + if (shouldFailNextSave) { + shouldFailNextSave = false + error("temporary save failure") + } storedProofs = it.getArgument(0) } } @@ -167,6 +173,57 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + assertTrue(storedProofs.isEmpty()) + } + private fun paymentProofRepo() = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, @@ -174,8 +231,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { store = store, ) - private fun paymentRequest(endpoint: String) = PaykitPaymentRequest( - paymentRequestId = PAYMENT_REQUEST_ID, + private fun paymentRequest( + endpoint: String, + paymentRequestId: String = PAYMENT_REQUEST_ID, + ) = PaykitPaymentRequest( + paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, counterpartyReceiverPath = PaykitReceiverPaths.WALLET, amountValue = "0.00001",