From 4fad8cebb133b864e216af10f8e53d1da7b76d82 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 12:41:36 -0500 Subject: [PATCH 1/5] fix: send hardware payment proofs --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 34 +++++++++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 31 +++++++++++++++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 893e5b1f7b..3fae83021a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -127,6 +127,7 @@ fun SendSheet( val navController = rememberNavController() LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> + appViewModel.completeHardwareContactPayment(result.txId) appViewModel.onSendSuccess( details = NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c403b78e12..e594ce0bee 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3489,12 +3489,7 @@ class AppViewModel @Inject constructor( } private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { - if ( - contactPaymentContext != null && - synchronized(contactPaymentContextLock) { preparedContactPaymentContext == contactPaymentContext } - ) { - return true - } + if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { @@ -3513,6 +3508,10 @@ class AppViewModel @Inject constructor( return true } + private fun isPreparedContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean = + contactPaymentContext != null && + synchronized(contactPaymentContextLock) { preparedContactPaymentContext == contactPaymentContext } + private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) val preparation = paymentProofPreparation() @@ -4344,7 +4343,28 @@ class AppViewModel @Inject constructor( suspend fun prepareHardwareContactPayment(): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - return prepareContactPayment(contactPaymentContext) + if (isPreparedContactPayment(contactPaymentContext)) return true + + val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( + onSuccess = { it }, + onFailure = { + handlePaymentPreparationFailure(it) + return false + }, + ) + if (!prepareContactPayment(contactPaymentContext)) { + cancelPaymentProofPreparation(preparedPaymentProofRequest) + return false + } + return true + } + + suspend fun completeHardwareContactPayment(txId: String) { + val incomingPaymentRequest = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.incomingPaymentRequest + } + completeOnchainPaymentProof(incomingPaymentRequest, txId) } fun onHardwareSignCancelled() { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2570838d05..bce6d1c020 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4162,8 +4162,35 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.prepareHardwareContactPayment()) assertTrue(sut.prepareHardwareContactPayment()) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) + inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { + verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + } + } + + @Test + fun `hardware payment request completes proof after broadcast`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ) + ) + + assertTrue(sut.prepareHardwareContactPayment()) + sut.completeHardwareContactPayment("txid") + + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } @Test From bbc3c1a86fb91a95a311eac8beb5e0ee64cc9669 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 12:43:29 -0500 Subject: [PATCH 2/5] chore: add changelog fragment --- changelog.d/next/1199.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1199.fixed.md diff --git a/changelog.d/next/1199.fixed.md b/changelog.d/next/1199.fixed.md new file mode 100644 index 0000000000..185d90931e --- /dev/null +++ b/changelog.d/next/1199.fixed.md @@ -0,0 +1 @@ +Hardware-wallet payments now send payment proofs for incoming Paykit requests. From 1d5007060400dd4442cf749ef2cdc055cf9fbef2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 08:22:06 -0500 Subject: [PATCH 3/5] fix: make payment proofs best effort --- .../repositories/PaykitPaymentProofRepo.kt | 8 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 35 ++---- .../PaykitPaymentProofRepoTest.kt | 26 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 118 +++++++++++++++++- 4 files changed, 159 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index f0b5b2d1d3..9b015e0d13 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -293,9 +293,11 @@ class PaykitPaymentProofRepo @Inject constructor( ) } } - removeProofsLocked { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId - } + runSuspendCatching { + removeProofsLocked { + PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId + } + }.onFailure { Logger.warn("Failed to clear a submitted Paykit payment proof", it, context = TAG) } } private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e594ce0bee..cc19b9edc8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3341,13 +3341,7 @@ class AppViewModel @Inject constructor( if (!validateIncomingPaymentRequest(contactPaymentContext)) return val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( - onSuccess = { it }, - onFailure = { - handlePaymentPreparationFailure(it) - return - }, - ) + var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).getOrNull() consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) @@ -3392,7 +3386,7 @@ class AppViewModel @Inject constructor( sendOnchain(address, amount, tags = tags) .onSuccess { txId -> preparedPaymentProofRequest = null - completeOnchainPaymentProof(incomingPaymentRequest, txId) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3432,8 +3426,7 @@ class AppViewModel @Inject constructor( val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) - return + preparedPaymentProofRequest = null } // Create pre-activity metadata before sending @@ -3528,12 +3521,14 @@ class AppViewModel @Inject constructor( ): Result = request?.let { paykitPaymentProofRepo.associateLightningPayment(it, paymentHash) } ?: Result.success(Unit) - private suspend fun completeOnchainPaymentProof(request: PaykitPaymentRequest?, txId: String) { - request?.let { + private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { + val paymentRequest = request ?: return + val endpointIdentifier = paymentProofPreparation().endpointIdentifier + viewModelScope.launch { paykitPaymentProofRepo.completeOnchainPayment( - request = it, + request = paymentRequest, txid = txId, - paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, + paymentEndpointIdentifier = endpointIdentifier, ) } } @@ -4346,13 +4341,7 @@ class AppViewModel @Inject constructor( if (isPreparedContactPayment(contactPaymentContext)) return true val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( - onSuccess = { it }, - onFailure = { - handlePaymentPreparationFailure(it) - return false - }, - ) + val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).getOrNull() if (!prepareContactPayment(contactPaymentContext)) { cancelPaymentProofPreparation(preparedPaymentProofRequest) return false @@ -4360,11 +4349,11 @@ class AppViewModel @Inject constructor( return true } - suspend fun completeHardwareContactPayment(txId: String) { + fun completeHardwareContactPayment(txId: String) { val incomingPaymentRequest = synchronized(contactPaymentContextLock) { activeContactPaymentContext?.incomingPaymentRequest } - completeOnchainPaymentProof(incomingPaymentRequest, txId) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) } fun onHardwareSignCancelled() { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 2833ebca67..6475678ebf 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -47,12 +47,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false + private var shouldFailProofRemoval = false @Before fun setUp() = test { storedProofs = emptyList() shouldFailNextLoad = false shouldFailNextSave = false + shouldFailProofRemoval = false whenever(store.hasPendingProofs()).thenReturn(true) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) @@ -64,11 +66,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs } whenever(store.save(any())).doSuspendableAnswer { + val proofs = it.getArgument>(0) if (shouldFailNextSave) { shouldFailNextSave = false error("temporary save failure") } - storedProofs = it.getArgument(0) + if (shouldFailProofRemoval && proofs.isEmpty()) { + shouldFailProofRemoval = false + error("temporary proof removal failure") + } + storedProofs = proofs } } @@ -328,6 +335,23 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `onchain proof cleanup failure does not retry delivery`() = 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() + shouldFailProofRemoval = true + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + assertEquals(txid, storedProofs.single().proofData) + } + @Test fun `onchain proof submits when prepared proof cannot be loaded`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bce6d1c020..0df9d4deb2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4059,6 +4059,83 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } + @Test + fun `proof preparation failure does not block incoming onchain payment`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + verify(lightningRepo).sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + } + + @Test + fun `proof association failure does not block incoming lightning payment`() = test { + val request = paymentRequest() + val bolt11 = "lnbcrt1paymentrequest" + val paymentHash = "010203" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(paykitPaymentProofRepo.associateLightningPayment(request, paymentHash)) + .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + 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.success(paymentHash)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = bolt11, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + decodedInvoice = lightningInvoice(bolt11, request.amountSats), + isPaymentRequest = true, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + } + @Test fun `pending incoming lightning payment keeps its proof association`() = test { val request = paymentRequest() @@ -4170,12 +4247,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware payment request completes proof after broadcast`() = test { + fun `proof preparation failure does not block hardware payment request`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + assertTrue(sut.prepareHardwareContactPayment()) + + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + } + + @Test + fun `hardware payment request completes proof in background after broadcast`() = test { val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val completionStarted = CompletableDeferred() + val finishCompletion = CompletableDeferred() whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) + whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue)) + .doSuspendableAnswer { + completionStarted.complete(Unit) + finishCompletion.await() + } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -4189,7 +4299,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.prepareHardwareContactPayment()) sut.completeHardwareContactPayment("txid") + runCurrent() + + completionStarted.await() + assertFalse(finishCompletion.isCompleted) + finishCompletion.complete(Unit) + advanceUntilIdle() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } From 35c0b4c8fc21dfb297f75aed9274c20ce6c7d48a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 08:30:42 -0500 Subject: [PATCH 4/5] test: exercise proof association failure --- .../to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0df9d4deb2..bfc7519ec8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4112,7 +4112,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "010203" val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendLightningSats = 100_000u) - whenever(paykitPaymentProofRepo.associateLightningPayment(request, paymentHash)) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) + .doSuspendableAnswer { + setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) + Result.success(Unit) + } + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any()) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -4124,7 +4129,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() { address = bolt11, amount = request.amountSats, payMethod = SendMethod.LIGHTNING, - decodedInvoice = lightningInvoice(bolt11, request.amountSats), isPaymentRequest = true, ), ) @@ -4132,6 +4136,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() + verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } From 1769cb249c65de4559b94c755b9e898a587beb4b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 12:07:22 -0500 Subject: [PATCH 5/5] fix: recover missing payment proofs --- .../repositories/PaykitPaymentProofRepo.kt | 60 ++++++++++++------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++- .../PaykitPaymentProofRepoTest.kt | 46 +++++++++++--- .../viewmodels/AppViewModelSendFlowTest.kt | 25 ++++++-- 4 files changed, 103 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 9b015e0d13..26038aade6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -78,24 +78,32 @@ class PaykitPaymentProofRepo @Inject constructor( }.onFailure { Logger.warn("Failed to prepare a Paykit payment proof", it, context = TAG) } } - suspend fun associateLightningPayment(request: PaykitPaymentRequest, paymentHash: String): Result = - withContext(ioDispatcher) { - runSuspendCatching { - if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable - operationMutex.withLock { - val proofs = loadProofs().toMutableList() - val index = proofs.indexOfLast { - it.requestId == request.id && - it.kind == PaykitPaymentProofKind.Lightning && - it.paymentIdentifier == null && - it.proofData == null - } - if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable - proofs[index] = proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) - persist(proofs) + suspend fun associateLightningPayment( + request: PaykitPaymentRequest, + paymentHash: String, + paymentEndpointIdentifier: String, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Lightning && + it.paymentIdentifier == null && + it.proofData == null } - }.onFailure { Logger.warn("Failed to associate a Paykit Lightning payment proof", it, context = TAG) } - } + val proof = if (index >= 0) { + proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) + } else { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Lightning) + .copy(paymentIdentifier = paymentHash.lowercase()) + } + if (index >= 0) proofs[index] = proof else proofs += proof + 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 @@ -150,12 +158,18 @@ class PaykitPaymentProofRepo @Inject constructor( it.paymentIdentifier == null && it.proofData == null } - if (index < 0) return@runSuspendCatching - val proof = proofs[index].copy( - paymentIdentifier = txid.lowercase(), - proofData = txid.lowercase(), - ) - proofs[index] = proof + val proof = if (index >= 0) { + proofs[index].copy( + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + } else { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + } + if (index >= 0) proofs[index] = proof else proofs += proof persistAndSubmit(listOf(proof), proofs) } completion.onFailure { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cc19b9edc8..dae74658dc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3518,7 +3518,13 @@ class AppViewModel @Inject constructor( private suspend fun associateLightningPaymentProof( request: PaykitPaymentRequest?, paymentHash: String, - ): Result = request?.let { paykitPaymentProofRepo.associateLightningPayment(it, paymentHash) } + ): Result = request?.let { + paykitPaymentProofRepo.associateLightningPayment( + request = it, + paymentHash = paymentHash, + paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, + ) + } ?: Result.success(Unit) private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 6475678ebf..f061139780 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -20,6 +20,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -110,7 +111,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val firstRepo = paymentProofRepo() firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() firstRepo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertEquals(PREIMAGE, storedProofs.single().proofData) @@ -185,7 +186,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val firstRepo = paymentProofRepo() firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + firstRepo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() assertNull(storedProofs.single().proofData) paymentProofRepo().reconcile() @@ -206,13 +207,28 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `lightning proof completes without prepared proof`() = 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.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `mismatched lightning preimage is not submitted`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) @@ -235,7 +251,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) @@ -248,7 +264,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) @@ -284,6 +300,22 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `onchain proof submits without prepared proof`() = 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.completeOnchainPayment(request, txid, endpoint) + + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any()) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `lightning retry preserves earlier payment correlation`() = test { val record = paymentRequestRecord() @@ -293,9 +325,9 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, "aa".repeat(32)).getOrThrow() + repo.associateLightningPayment(request, "aa".repeat(32), MethodId.Bolt11.rawValue).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bfc7519ec8..76c59f2e27 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -304,7 +304,13 @@ 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 { + paykitPaymentProofRepo.associateLightningPayment( + any(), + any(), + any(), + ) + }.thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -3943,6 +3949,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isMaxAmount = false, tags = emptyList(), ) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } @Test @@ -4117,7 +4124,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) Result.success(Unit) } - whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any()) } + whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -4137,7 +4144,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) - verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash) + verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } @@ -4176,7 +4183,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).associateLightningPayment(request, invoicePaymentHash) + verify(paykitPaymentProofRepo).associateLightningPayment( + request, + invoicePaymentHash, + MethodId.Bolt11.rawValue, + ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) @@ -4217,7 +4228,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).associateLightningPayment(request, invoicePaymentHash) + verify(paykitPaymentProofRepo).associateLightningPayment( + request, + invoicePaymentHash, + MethodId.Bolt11.rawValue, + ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash) verify(paykitPaymentProofRepo).cancelPreparation(request)