From 70306b1b6245041312e332ef7ae8848b5c024e7c Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:17:25 -0500 Subject: [PATCH 1/8] 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 ab88c2cd7d..fefa721567 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 0000000000..40ad645d3d --- /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 0000000000..30832183ab --- /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 c54e3ac4ab..fcf69c9e75 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 91d20b209e..908449493f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -144,7 +144,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 @@ -238,6 +241,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, @@ -724,6 +728,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 return paykitPaymentRequestRepo.refresh().fold( onSuccess = { @@ -1356,6 +1361,7 @@ class AppViewModel @Inject constructor( ) val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId if (paymentHash != null) { + paykitPaymentProofRepo.failLightningPayment(paymentHash) refreshPaymentActivity(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) @@ -1439,6 +1445,7 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { val paymentHash = event.paymentHash + paykitPaymentProofRepo.completeLightningPayment(paymentHash, event.paymentPreimage) val isQuickPay = quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = paymentHash, @@ -3325,18 +3332,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 } @@ -3357,6 +3375,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { + cancelPaymentProof(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -3370,6 +3389,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( @@ -3384,6 +3405,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, @@ -3406,6 +3428,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()) { @@ -3421,6 +3448,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( @@ -3432,12 +3461,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) @@ -3480,6 +3511,47 @@ class AppViewModel @Inject constructor( return true } + 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 @@ -4773,6 +4845,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 0000000000..877c57478d --- /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 c4af4a99cc..4edae4a75c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -93,7 +93,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 @@ -189,6 +192,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() @@ -296,6 +300,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)) @@ -384,6 +390,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicPaykitRepo = publicPaykitRepo, privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, + paykitPaymentProofRepo = paykitPaymentProofRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -2328,6 +2335,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { feePaidMsat = 10uL, failureReason = null, ) + verify(paykitPaymentProofRepo).completeLightningPayment(paymentHash, "preimage") } @Test @@ -2360,6 +2368,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { feePaidMsat = null, failureReason = PaymentFailureReason.RETRIES_EXHAUSTED, ) + verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -3993,10 +4002,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 @@ -4856,7 +4867,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 0000000000..2a91491eea --- /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 fb8122b7a1da93b8298f833f121faec45f18856a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:19:26 -0500 Subject: [PATCH 2/8] 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 40103f46e0f011d55e82c1cf91b9529b56ec7fcc Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:42:28 -0500 Subject: [PATCH 3/8] fix: harden paykit proof delivery --- .../repositories/PaykitPaymentProofRepo.kt | 98 +++++++++++++------ .../repositories/PaykitPaymentProofStore.kt | 14 +-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 30 +++--- .../PaykitPaymentProofRepoTest.kt | 64 +++++++++++- 4 files changed, 149 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 40ad645d3d..1cbcfe322b 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 30832183ab..e03a7fe196 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 908449493f..6090a165f3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1361,7 +1361,7 @@ class AppViewModel @Inject constructor( ) val paymentHash = event.paymentHash ?: outcome.invoicePaymentHash ?: event.paymentId if (paymentHash != null) { - paykitPaymentProofRepo.failLightningPayment(paymentHash) + viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) } refreshPaymentActivity(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) @@ -1445,7 +1445,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { val paymentHash = event.paymentHash - paykitPaymentProofRepo.completeLightningPayment(paymentHash, event.paymentPreimage) + viewModelScope.launch { + paykitPaymentProofRepo.completeLightningPayment(paymentHash, event.paymentPreimage) + } val isQuickPay = quickPayRepo.signalCompletion( paymentId = event.paymentId, paymentHash = paymentHash, @@ -3348,13 +3350,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 } @@ -3375,7 +3377,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - cancelPaymentProof(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -3405,7 +3407,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, @@ -3429,7 +3431,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 } @@ -3449,7 +3451,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( @@ -3468,7 +3469,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) @@ -3513,8 +3515,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, @@ -3532,11 +3533,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) { @@ -3545,7 +3546,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 877c57478d..320026e495 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", From 67fb4d9d71e20ff99ed48b33d3c80cf864091406 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 25 Aug 2026 10:38:52 -0500 Subject: [PATCH 4/8] fix: harden paykit proof recovery --- .../repositories/PaykitPaymentProofRepo.kt | 71 ++++++++++----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 +- .../PaykitPaymentProofRepoTest.kt | 89 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 84 ++++++++++++++++- 4 files changed, 226 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 1cbcfe322b..cf62ca5c1f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -64,30 +64,15 @@ class PaykitPaymentProofRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - if ( - paymentEndpointIdentifier !in request.acceptedPaymentEndpointIdentifiers || - !endpointSupports(paymentEndpointIdentifier, kind) - ) { - throw PaykitPaymentRequestError.RequestUnavailable - } - val identityStatus = paykitSdkService.identityStatus() - if (identityStatus?.liveSessionAvailable != true) throw PaykitPaymentRequestError.RequestUnavailable - val publicKey = identityStatus.publicKey ?: throw PaykitPaymentRequestError.RequestUnavailable - val identity = PubkyPublicKeyFormat.normalized(publicKey) - ?: throw PaykitPaymentRequestError.RequestUnavailable + val proof = pendingProof(request, paymentEndpointIdentifier, kind) val proofs = loadProofs() .filterNot { - PubkyPublicKeyFormat.matches(it.identity, identity) && + PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null } + - PendingPaykitPaymentProof( - identity = identity, - requestId = request.id, - paymentEndpointIdentifier = paymentEndpointIdentifier, - kind = kind, - ) + proof persist(proofs) } }.onFailure { Logger.warn("Failed to prepare a Paykit payment proof", it, context = TAG) } @@ -146,14 +131,18 @@ class PaykitPaymentProofRepo @Inject constructor( } } - suspend fun completeOnchainPayment(request: PaykitPaymentRequest, txid: String) = withContext(ioDispatcher) { + 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 { - runSuspendCatching { + val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { it.requestId == request.id && @@ -168,7 +157,23 @@ class PaykitPaymentProofRepo @Inject constructor( ) proofs[index] = proof persistAndSubmit(listOf(proof), proofs) - }.onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + } + 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) } + } } } @@ -305,6 +310,30 @@ class PaykitPaymentProofRepo @Inject constructor( 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) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6090a165f3..85c7ccebc2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3530,7 +3530,13 @@ class AppViewModel @Inject constructor( ?: Result.success(Unit) private suspend fun completeOnchainPaymentProof(request: PaykitPaymentRequest?, txId: String) { - request?.let { paykitPaymentProofRepo.completeOnchainPayment(it, txId) } + request?.let { + paykitPaymentProofRepo.completeOnchainPayment( + request = it, + txid = txId, + paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, + ) + } } private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 320026e495..bace953693 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -12,6 +12,10 @@ 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 @@ -41,15 +45,23 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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 { storedProofs } + whenever(store.load()).thenAnswer { + if (shouldFailNextLoad) { + shouldFailNextLoad = false + error("temporary load failure") + } + storedProofs + } whenever(store.save(any())).doSuspendableAnswer { if (shouldFailNextSave) { shouldFailNextSave = false @@ -95,6 +107,46 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { 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) @@ -154,7 +206,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() - repo.completeOnchainPayment(request, txid) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -218,12 +270,43 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid) + 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, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4edae4a75c..03288f242a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4007,7 +4007,89 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid") + 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 From e6a509e2327f8198655d291019b2082ec980b27d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 12:35:19 -0500 Subject: [PATCH 5/8] fix: harden payment proof polling --- .../repositories/PaykitPaymentProofRepo.kt | 9 ++++- .../repositories/PaykitPaymentProofStore.kt | 25 +++++++++---- .../PaykitPaymentProofRepoTest.kt | 21 +++++++++++ .../PaykitPaymentProofStoreTest.kt | 36 +++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index cf62ca5c1f..ec907cadd2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -186,13 +186,20 @@ class PaykitPaymentProofRepo @Inject constructor( } suspend fun reconcile() = withContext(ioDispatcher) { + if (!store.hasPendingProofs()) return@withContext + operationMutex.withLock { runSuspendCatching { + val storedProofs = loadProofs() + if (storedProofs.isEmpty()) { + persist(emptyList()) + return@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 proofs = storedProofs.filter { PubkyPublicKeyFormat.matches(it.identity, identity) } val payments = if (proofs.any { it.kind == PaykitPaymentProofKind.Lightning && it.proofData == null }) { lightningRepo.getPayments().getOrDefault(emptyList()) } else { diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index e03a7fe196..4c9ab7474b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain +import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -12,20 +13,32 @@ import javax.inject.Singleton class PaykitPaymentProofStore @Inject constructor( private val keychain: Keychain, ) { + companion object { + private const val TAG = "PaykitPaymentProofStore" + private val KEY = Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name + } + @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 + val value = keychain.loadString(KEY) ?: return emptyList() + return runCatching { Json.decodeFromString(value).proofs } + .getOrElse { + Logger.warn("Discarded corrupt pending Paykit payment proof state", it, context = TAG) + emptyList() + } } suspend fun save(proofs: List) { - keychain.upsertString( - Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, - Json.encodeToString(State(proofs)), - ) + if (proofs.isEmpty()) { + keychain.delete(KEY) + } else { + keychain.upsertString(KEY, Json.encodeToString(State(proofs))) + } } + + fun hasPendingProofs(): Boolean = keychain.exists(KEY) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index bace953693..f8b100f773 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -53,6 +53,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = emptyList() shouldFailNextLoad = false shouldFailNextSave = false + whenever(store.hasPendingProofs()).thenReturn(true) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(store.load()).thenAnswer { @@ -71,6 +72,26 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `reconcile avoids Paykit and proof loading without persisted proofs`() = test { + whenever(store.hasPendingProofs()).thenReturn(false) + + paymentProofRepo().reconcile() + + verify(store, never()).load() + verify(paykitSdkService, never()).identityStatus() + verify(lightningRepo, never()).getPayments() + } + + @Test + fun `reconcile removes persisted empty proof state without using Paykit`() = test { + paymentProofRepo().reconcile() + + verify(store).load() + verify(store).save(emptyList()) + verify(paykitSdkService, never()).identityStatus() + } + @Test fun `completed lightning proof retries after repository restart`() = test { val record = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt new file mode 100644 index 0000000000..d9659a59c8 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt @@ -0,0 +1,36 @@ +package to.bitkit.repositories + +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +class PaykitPaymentProofStoreTest : BaseUnitTest() { + companion object { + private val KEY = Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name + } + + @Test + fun `loading corrupt state returns no proofs`() { + val keychain = mock() + whenever(keychain.loadString(KEY)).thenReturn("not-json") + + assertTrue(PaykitPaymentProofStore(keychain).load().isEmpty()) + } + + @Test + fun `saving no proofs removes persisted state`() = test { + val keychain = mock() + + PaykitPaymentProofStore(keychain).save(emptyList()) + + verify(keychain).delete(KEY) + verify(keychain, never()).upsertString(eq(KEY), any()) + } +} From e0411500365c6679feca2463bbdef70229b400cb Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 12:49:17 -0500 Subject: [PATCH 6/8] fix: enable request payment swipe --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 85c7ccebc2..3e6e0214d0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2795,6 +2795,7 @@ class AppViewModel @Inject constructor( return } + _sendUiState.update { it.copy(isAmountInputValid = validateAmount(amount)) } navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) refreshOnchainSendIfNeeded() estimateLightningRoutingFeesIfNeeded() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 03288f242a..947c252ccf 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -3711,6 +3711,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isAmountInputValid) assertTrue(sut.sendUiState.value.isPaymentRequest) assertEquals( ContactPaymentContext(testPublicKey, privateContext, request), From ad132c893ca9ae14ddbf2b3bbc8fcd5c66d244fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 07:27:13 -0500 Subject: [PATCH 7/8] fix: isolate proof reconciliation --- .../repositories/PaykitPaymentProofRepo.kt | 11 +++- .../PaykitPaymentProofRepoTest.kt | 51 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index ec907cadd2..f0b5b2d1d3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -206,7 +206,16 @@ class PaykitPaymentProofRepo @Inject constructor( emptyList() } - proofs.forEach { reconcileProof(it, payments) } + proofs.forEach { proof -> + runSuspendCatching { reconcileProof(proof, payments) } + .onFailure { + Logger.warn( + "Failed to reconcile a pending Paykit payment proof", + it, + context = TAG, + ) + } + } }.onFailure { Logger.warn("Failed to reconcile pending Paykit payment proofs", it, context = TAG) } } } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f8b100f773..2833ebca67 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -128,6 +128,37 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() } + @Test + fun `failed proof reconciliation does not stop later proofs`() = test { + val secondPaymentRequestId = "550e8400-e29b-41d4-a716-446655440001" + storedProofs = listOf( + readyLightningProof(PAYMENT_REQUEST_ID), + readyLightningProof(secondPaymentRequestId), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord(paymentRequestId = PAYMENT_REQUEST_ID), + paymentRequestRecord(paymentRequestId = secondPaymentRequestId), + ), + ) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())) + .thenThrow(IllegalStateException("temporary failure")) + .thenReturn(paymentRequestRecord(paymentRequestId = secondPaymentRequestId)) + + paymentProofRepo().reconcile() + + val paymentRequestIdCaptor = argumentCaptor() + verify(paykitSdkService, times(2)).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = paymentRequestIdCaptor.capture(), + paymentEndpointIdentifier = any(), + proofJson = any(), + ) + assertEquals(listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), paymentRequestIdCaptor.allValues) + assertEquals(listOf(PAYMENT_REQUEST_ID), storedProofs.map { it.requestId.paymentRequestId }) + } + @Test fun `associated lightning proof completes after repository restart`() = test { val record = paymentRequestRecord() @@ -348,10 +379,26 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { acceptedPaymentEndpointIdentifiers = listOf(endpoint), ) - private fun paymentRequestRecord(paymentProofs: List = emptyList()) = PaymentRequestRecord( + private fun readyLightningProof(paymentRequestId: String) = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = PaykitPaymentRequestId( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.WALLET, + paymentRequestId = paymentRequestId, + ), + paymentEndpointIdentifier = MethodId.Bolt11.rawValue, + kind = PaykitPaymentProofKind.Lightning, + paymentIdentifier = PAYMENT_HASH, + proofData = PREIMAGE, + ) + + private fun paymentRequestRecord( + paymentProofs: List = emptyList(), + paymentRequestId: String = PAYMENT_REQUEST_ID, + ) = PaymentRequestRecord( counterparty = COUNTERPARTY, counterpartyReceiverPath = PaykitReceiverPaths.WALLET, - paymentRequestId = PAYMENT_REQUEST_ID, + paymentRequestId = paymentRequestId, localRole = PaymentRequestLocalRole.PAYER, state = PaymentRequestLifecycleState.PROPOSED, proposalStreamItemId = 1uL, From 10ff0df74eedf179561b4ba7bda9bb68fe513845 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 12:51:19 -0500 Subject: [PATCH 8/8] test: cover onchain payment requests --- .../java/to/bitkit/services/CoreService.kt | 5 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +- .../viewmodels/AppViewModelSendFlowTest.kt | 50 ++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index ca79cfff71..8dc09d80c0 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -27,6 +27,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection +import com.synonym.bitkitcore.ValidationResult import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags import com.synonym.bitkitcore.createCjitEntry @@ -223,6 +224,10 @@ class CoreService @Inject constructor( com.synonym.bitkitcore.decode(input) } + suspend fun validateBitcoinAddress(address: String): ValidationResult = ServiceQueue.CORE.background { + com.synonym.bitkitcore.validateBitcoinAddress(address) + } + suspend fun getLnurlInvoiceForPayData( data: LnurlPayData, amountMsats: ULong, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3e6e0214d0..c403b78e12 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -24,7 +24,6 @@ import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection -import com.synonym.bitkitcore.validateBitcoinAddress import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList @@ -1797,7 +1796,7 @@ class AppViewModel @Inject constructor( } private suspend fun validateOnChainAddress(invoice: OnChainInvoice) { - val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } + val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { showAddressValidationError( titleRes = R.string.other__scan_err_decoding, @@ -2686,7 +2685,7 @@ class AppViewModel @Inject constructor( scanResult: String, fromMainScanner: Boolean, ) { - val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } + val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { hideSheet() toast( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 947c252ccf..2570838d05 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,12 +9,15 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.ValidationResult import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CancellationException @@ -38,8 +41,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.PaymentFailureReason +import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -3725,6 +3728,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(request.id in surfacedPaykitPaymentRequestIds) } + @Test + fun `incoming onchain payment request has a valid fixed amount`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { coreService.decode(REGTEST_ADDRESS) }.thenReturn( + Scanner.OnChain( + OnChainInvoice( + address = REGTEST_ADDRESS, + amountSatoshis = 0u, + label = null, + message = null, + params = emptyMap(), + ) + ) + ) + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult( + address = REGTEST_ADDRESS, + network = NetworkType.REGTEST, + addressType = AddressType.P2WPKH, + ) + ) + sut.setIsAuthenticated(true) + runCurrent() + + sut.openContactPayment( + paymentRequest = REGTEST_ADDRESS, + publicKey = testPublicKey, + privatePaymentContext = privateContext, + incomingPaymentRequest = request, + ) + runCurrent() + + verify(coreService).decode(REGTEST_ADDRESS) + assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isAmountInputValid) + assertTrue(sut.sendUiState.value.isPaymentRequest) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertEquals( + ContactPaymentContext(testPublicKey, privateContext, request), + activeContactPaymentContext(), + ) + } + @Test fun `outgoing payment request creation continues after its caller returns`() = test { val request = paymentRequest().copy(counterparty = "pubkyrecipient")