From 129bd6b8ce1c479c7a62b44c5676c33edecf2a98 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:17:27 -0500 Subject: [PATCH 1/8] feat: send paykit payment proofs --- Bitkit/AppScene.swift | 1 + .../Services/PaykitPaymentProofService.swift | 368 ++++++++++++++++++ Bitkit/Services/PubkyService.swift | 16 + Bitkit/Utilities/Keychain.swift | 2 + Bitkit/ViewModels/AppViewModel.swift | 11 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 26 ++ .../Wallets/Send/SendConfirmationView.swift | 58 ++- .../PaykitPaymentProofServiceTests.swift | 322 +++++++++++++++ changelog.d/next/payment-proofs.added.md | 1 + 9 files changed, 797 insertions(+), 8 deletions(-) create mode 100644 Bitkit/Services/PaykitPaymentProofService.swift create mode 100644 BitkitTests/PaykitPaymentProofServiceTests.swift create mode 100644 changelog.d/next/payment-proofs.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 35b222c27..ab8f05a7f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -773,6 +773,7 @@ struct AppScene: View { return false } + await PaykitPaymentProofService.shared.reconcile() let previousRequests = paykitPaymentRequestManager.pendingRequests await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) await paykitPaymentRequestManager.refresh() diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift new file mode 100644 index 000000000..5e753d9d7 --- /dev/null +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -0,0 +1,368 @@ +import CryptoKit +import Foundation +import LDKNode +import Paykit + +enum PaykitPaymentProofKind: String, Codable { + case lightning = "bitcoin-bolt11-preimage" + case onchain = "bitcoin-onchain-txid" +} + +struct PendingPaykitPaymentProof: Codable, Equatable { + let identity: String + let requestId: PaykitPaymentRequest.ID + let paymentEndpointIdentifier: String + let kind: PaykitPaymentProofKind + var paymentIdentifier: String? + var proofData: String? +} + +protocol PaykitPaymentProofStoring: Sendable { + func load() async throws -> [PendingPaykitPaymentProof] + func save(_ proofs: [PendingPaykitPaymentProof]) async throws +} + +struct PaykitPaymentProofStore: PaykitPaymentProofStoring { + private struct State: Codable { + var proofs: [PendingPaykitPaymentProof] + } + + func load() async throws -> [PendingPaykitPaymentProof] { + guard let data = try Keychain.load(key: .paykitPendingPaymentProofs) else { return [] } + return try JSONDecoder().decode(State.self, from: data).proofs + } + + func save(_ proofs: [PendingPaykitPaymentProof]) async throws { + try Keychain.upsert( + key: .paykitPendingPaymentProofs, + data: JSONEncoder().encode(State(proofs: proofs)) + ) + } +} + +protocol PaykitPaymentProofSdkHandling: Sendable { + func identityStatus() async throws -> Paykit.IdentityStatus? + func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] + func submitPaymentProof( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + proof: Paykit.PaymentProofSubmission + ) async throws -> Paykit.PaymentRequestRecord +} + +extension PaykitSdkService: PaykitPaymentProofSdkHandling {} + +enum PaykitLightningPaymentProofStatus: Equatable { + case pending + case succeeded(preimage: String?) + case failed + case unknown +} + +protocol PaykitLightningPaymentProofLookingUp: Sendable { + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus +} + +struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + guard let payment = await LightningService.shared.listPayments()?.first(where: { + $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame + }), payment.direction == .outbound else { + return .unknown + } + + switch payment.status { + case .pending: + return .pending + case .failed: + return .failed + case .succeeded: + guard case let .bolt11(_, preimage, _, _, _) = payment.kind else { return .unknown } + return .succeeded(preimage: preimage) + } + } +} + +actor PaykitPaymentProofService { + static let shared = PaykitPaymentProofService() + + private let sdk: any PaykitPaymentProofSdkHandling + private let store: any PaykitPaymentProofStoring + private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp + private let logInfo: @Sendable (String) -> Void + private let logWarning: @Sendable (String) -> Void + private var proofs: [PendingPaykitPaymentProof]? + + init( + sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, + store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), + lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + logInfo: @escaping @Sendable (String) -> Void = { + Logger.info($0, context: "PaykitPaymentProof") + }, + logWarning: @escaping @Sendable (String) -> Void = { + Logger.warn($0, context: "PaykitPaymentProof") + } + ) { + self.sdk = sdk + self.store = store + self.lightningPaymentLookup = lightningPaymentLookup + self.logInfo = logInfo + self.logWarning = logWarning + } + + func prepare( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind + ) async throws { + guard request.acceptedPaymentEndpointIdentifiers.contains(paymentEndpointIdentifier), + Self.endpoint(paymentEndpointIdentifier, supports: kind), + let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + let publicKey = identityStatus.publicKey, + let identity = PubkyPublicKeyFormat.normalized(publicKey) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + + var pendingProofs = try await loadProofs() + pendingProofs.removeAll { + PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id + } + pendingProofs.append(PendingPaykitPaymentProof( + identity: identity, + requestId: request.id, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind, + paymentIdentifier: nil, + proofData: nil + )) + try await persist(pendingProofs) + } + + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + guard Self.isHex(paymentHash, byteCount: 32) else { + throw PaykitPaymentRequestError.requestUnavailable + } + + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.firstIndex(where: { $0.requestId == request.id && $0.kind == .lightning }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + pendingProofs[index].paymentIdentifier = paymentHash.lowercased() + try await persist(pendingProofs) + } + + func completeLightningPayment(paymentHash: String, preimage: String?) async { + guard let preimage, + Self.preimage(preimage, matchesPaymentHash: paymentHash) + else { + if preimage != nil { + logWarning("Ignored a Paykit Lightning proof whose preimage did not match its payment hash") + } + return + } + + do { + var pendingProofs = try await loadProofs() + let indexes = pendingProofs.indices.filter { + pendingProofs[$0].kind == .lightning && + pendingProofs[$0].paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame + } + guard !indexes.isEmpty else { return } + for index in indexes { + pendingProofs[index].proofData = preimage.lowercased() + } + try await persist(pendingProofs) + for index in indexes.reversed() { + await submit(pendingProofs[index]) + } + } catch { + logWarning("Failed to persist a completed Paykit Lightning payment proof: \(error)") + } + } + + func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String) async { + guard Self.isHex(txid, byteCount: 32) else { + logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") + return + } + + do { + var pendingProofs = try await loadProofs() + guard let index = pendingProofs.firstIndex(where: { $0.requestId == request.id && $0.kind == .onchain }) else { return } + pendingProofs[index].paymentIdentifier = txid.lowercased() + pendingProofs[index].proofData = txid.lowercased() + try await persist(pendingProofs) + await submit(pendingProofs[index]) + } catch { + logWarning("Failed to persist a completed Paykit on-chain payment proof: \(error)") + } + } + + func failLightningPayment(paymentHash: String) async { + await removeProofs { + $0.kind == .lightning && $0.paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame + } + } + + func cancel(_ request: PaykitPaymentRequest) async { + await removeProofs { $0.requestId == request.id } + } + + func reconcile() async { + do { + guard let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + let publicKey = identityStatus.publicKey, + let identity = PubkyPublicKeyFormat.normalized(publicKey) + else { return } + + let pendingProofs = try await loadProofs().filter { + PubkyPublicKeyFormat.matches($0.identity, identity) + } + for proof in pendingProofs { + if proof.proofData != nil { + await submit(proof) + continue + } + guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } + switch await lightningPaymentLookup.status(paymentHash: paymentHash) { + case .pending, .unknown: + continue + case .failed: + await failLightningPayment(paymentHash: paymentHash) + case let .succeeded(preimage): + await completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + } + } + } catch { + logWarning("Failed to reconcile pending Paykit payment proofs: \(error)") + } + } + + private func submit(_ pendingProof: PendingPaykitPaymentProof) async { + guard let proofData = pendingProof.proofData else { return } + do { + guard let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + PubkyPublicKeyFormat.matches(identityStatus.publicKey, pendingProof.identity) + else { return } + + let records = try await sdk.paymentRequests() + guard let request = records.first(where: { + $0.paymentRequestId == pendingProof.requestId.paymentRequestId && + PubkyPublicKeyFormat.matches($0.counterparty, pendingProof.requestId.counterparty) && + $0.counterpartyReceiverPath == pendingProof.requestId.counterpartyReceiverPath + }) else { return } + + let proofText = try Self.proofText(kind: pendingProof.kind, data: proofData) + if request.paymentProofs.contains(where: { + $0.billingPeriod == nil && + $0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier && + Self.proofValues($0.proof.exportText()) == Self.proofValues(proofText) + }) { + await removeProof(pendingProof) + return + } + + _ = try await sdk.submitPaymentProof( + counterparty: pendingProof.requestId.counterparty, + counterpartyReceiverPath: pendingProof.requestId.counterpartyReceiverPath, + paymentRequestId: pendingProof.requestId.paymentRequestId, + proof: Paykit.PaymentProofSubmission( + billingPeriod: nil, + paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, + proof: Paykit.PrivateJsonObject(text: proofText) + ) + ) + await removeProof(pendingProof) + logInfo("Queued a Paykit payment proof for private delivery") + do { + _ = try await sdk.processPendingPrivateMessages() + } catch { + logWarning("Paykit payment proof remains queued for private delivery: \(error)") + } + } catch { + logWarning("Failed to queue a Paykit payment proof: \(error)") + } + } + + private func loadProofs() async throws -> [PendingPaykitPaymentProof] { + if let proofs { return proofs } + let storedProofs = try await store.load() + proofs = storedProofs + return storedProofs + } + + private func persist(_ proofs: [PendingPaykitPaymentProof]) async throws { + try await store.save(proofs) + self.proofs = proofs + } + + private func removeProof(_ proof: PendingPaykitPaymentProof) async { + await removeProofs { $0 == proof } + } + + private func removeProofs(where shouldRemove: (PendingPaykitPaymentProof) -> Bool) async { + do { + let pendingProofs = try await loadProofs() + let remainingProofs = pendingProofs.filter { !shouldRemove($0) } + guard remainingProofs != pendingProofs else { return } + try await persist(remainingProofs) + } catch { + logWarning("Failed to clear a pending Paykit payment proof: \(error)") + } + } + + private static func endpoint(_ identifier: String, supports kind: PaykitPaymentProofKind) -> Bool { + guard let methodId = PublicPaykitService.MethodId(rawValue: identifier) else { return false } + switch kind { + case .lightning: + return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl + case .onchain: + return methodId.onchainNetwork != nil + } + } + + private static func preimage(_ preimage: String, matchesPaymentHash paymentHash: String) -> Bool { + guard let bytes = data(hex: preimage), bytes.count == 32 else { return false } + return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + .caseInsensitiveCompare(paymentHash) == .orderedSame + } + + private static func isHex(_ value: String, byteCount: Int) -> Bool { + data(hex: value)?.count == byteCount + } + + private static func data(hex: String) -> Data? { + guard hex.count.isMultiple(of: 2), hex.allSatisfy(\.isHexDigit) else { return nil } + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index ..< nextIndex], radix: 16) else { return nil } + data.append(byte) + index = nextIndex + } + return data + } + + private static func proofText(kind: PaykitPaymentProofKind, data: String) throws -> String { + let encoded = try JSONSerialization.data( + withJSONObject: ["data": data, "type": kind.rawValue], + options: [.sortedKeys] + ) + return String(decoding: encoded, as: UTF8.self) + } + + private static func proofValues(_ text: String) -> [String: String]? { + guard let data = text.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } + return values + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 0e79b62e1..4b5045f44 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -692,6 +692,22 @@ actor PaykitSdkService { } } + func submitPaymentProof( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + proof: Paykit.PaymentProofSubmission + ) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking { sdk in + try await sdk.submitPaymentProof( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: paymentRequestId, + proof: proof + ) + } + } + func proposePaymentRequest( counterparty: String, counterpartyReceiverPath: String, diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 19ee2a9d0..f3ff75d0d 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -7,6 +7,7 @@ enum KeychainEntryType { case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin case paykitSession + case paykitPendingPaymentProofs case paykitPresentedPaymentRequests case paykitReceiverNoiseSecretKey case paykitSdkState @@ -19,6 +20,7 @@ enum KeychainEntryType { case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" case .paykitSession: "paykit_session" + case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 6e53d2a8f..4fb599759 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1074,7 +1074,13 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): + case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat): + Task { + await PaykitPaymentProofService.shared.completeLightningPayment( + paymentHash: paymentHash, + preimage: paymentPreimage + ) + } let outcome = QuickPayPaymentCoordinator.shared.complete( paymentId: paymentId, paymentHash: paymentHash, @@ -1106,6 +1112,9 @@ extension AppViewModel { success: false ) let hash = paymentHash ?? outcome.invoicePaymentHash ?? paymentId + if let paymentHash = paymentHash ?? paymentId { + Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } + } let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false if let hash, awaitingSheet { pendingPaymentHashes.remove(hash) diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index e6c938cfa..e0a7ded67 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -199,10 +199,24 @@ struct LnurlPayConfirm: View { let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey + let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var bolt11Invoice: String? + var shouldCancelPaymentProof = false do { try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) + if let incomingPaymentRequest { + let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + guard incomingPaymentRequest.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await PaykitPaymentProofService.shared.prepare( + request: incomingPaymentRequest, + paymentEndpointIdentifier: endpointIdentifier, + kind: .lightning + ) + shouldCancelPaymentProof = true + } try await prepareIncomingPaymentRequest() try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) @@ -216,6 +230,12 @@ struct LnurlPayConfirm: View { let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) + if let incomingPaymentRequest { + try await PaykitPaymentProofService.shared.associateLightningPayment( + incomingPaymentRequest, + paymentHash: paymentHash + ) + } // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) // LNURL server returns invoices with the amount baked in, so pass sats: nil @@ -228,13 +248,19 @@ struct LnurlPayConfirm: View { navigationPath.append(.pending(paymentHash: timedOutHash, retryRoute: .lnurlPayConfirm, paymentRequest: bolt11)) } ) + shouldCancelPaymentProof = false + await PaykitPaymentProofService.shared.reconcile() app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) Logger.info("LNURL payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { // onTimeout callback already navigated to .pending; suppress throw + shouldCancelPaymentProof = false return } catch { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + } Logger.error("LNURL payment failed: \(error)") navigationPath.append(.failure(SendFailureContext( diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 934b94c4c..480538c31 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -581,9 +581,23 @@ struct SendConfirmationView: View { var createdMetadataPaymentId: String? = nil let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey + let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + var shouldCancelPaymentProof = false do { - try await prepareContactPaymentIfNeeded() + try validateIncomingPaymentRequestContext(contactPaymentContext) + try validateIncomingPaymentRequestAmounts(contactPaymentContext) + if let incomingPaymentRequest { + let proof = try paymentProofPreparation(for: incomingPaymentRequest) + try await PaykitPaymentProofService.shared.prepare( + request: incomingPaymentRequest, + paymentEndpointIdentifier: proof.endpointIdentifier, + kind: proof.kind + ) + shouldCancelPaymentProof = true + } + try await prepareIncomingPaymentRequest() + try validateIncomingPaymentRequestContext(contactPaymentContext) if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis @@ -592,6 +606,12 @@ struct SendConfirmationView: View { // Create pre-activity metadata for tags and activity address let paymentHash = invoice.paymentHash.hex + if let incomingPaymentRequest { + try await PaykitPaymentProofService.shared.associateLightningPayment( + incomingPaymentRequest, + paymentHash: paymentHash + ) + } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) @@ -608,11 +628,14 @@ struct SendConfirmationView: View { navigationPath.append(.pending(paymentHash: timedOutHash, retryRoute: .confirm, paymentRequest: invoice.bolt11)) } ) + shouldCancelPaymentProof = false + await PaykitPaymentProofService.shared.reconcile() await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { // onTimeout callback already navigated to .pending; suppress throw + shouldCancelPaymentProof = false return } catch { throw error @@ -621,6 +644,10 @@ struct SendConfirmationView: View { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) + shouldCancelPaymentProof = false + if let incomingPaymentRequest { + await PaykitPaymentProofService.shared.completeOnchainPayment(incomingPaymentRequest, txid: txid) + } // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) @@ -647,6 +674,9 @@ struct SendConfirmationView: View { ) } } catch { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + } Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { @@ -662,12 +692,26 @@ struct SendConfirmationView: View { } } - private func prepareContactPaymentIfNeeded() async throws { - let context = app.contactPaymentContext - try validateIncomingPaymentRequestContext(context) - try validateIncomingPaymentRequestAmounts(context) - try await prepareIncomingPaymentRequest() - try validateIncomingPaymentRequestContext(context) + private func paymentProofPreparation( + for request: PaykitPaymentRequest + ) throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { + switch app.selectedWalletToPayFrom { + case .lightning: + let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + guard request.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return (endpointIdentifier, .lightning) + case .onchain: + guard let address = app.scannedOnchainInvoice?.address else { + throw PaykitPaymentRequestError.requestUnavailable + } + let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + guard request.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { + throw PaykitPaymentRequestError.requestUnavailable + } + return (endpointIdentifier, .onchain) + } } private func validateIncomingPaymentRequestContext(_ context: ContactPaymentContext?) throws { diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift new file mode 100644 index 000000000..2a466b74d --- /dev/null +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -0,0 +1,322 @@ +@testable import Bitkit +import Paykit +import XCTest + +@MainActor +final class PaykitPaymentProofServiceTests: XCTestCase { + private let identity = "pubky\(String(repeating: "z", count: 52))" + private let counterparty = "pubky\(String(repeating: "y", count: 52))" + private let paymentHash = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" + private let preimage = String(repeating: "00", count: 32) + + func testCompletedLightningPaymentRetriesAfterRestart() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + + let service = paymentProofService(sdk: sdk, store: store) + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let failedSubmissionCount = await sdk.submissionCount() + let persistedProof = await store.snapshot().first + XCTAssertEqual(failedSubmissionCount, 1) + XCTAssertEqual(persistedProof?.proofData, preimage) + + await sdk.setSubmissionFailure(false) + let restartedService = paymentProofService( + sdk: sdk, + store: store, + lightningStatus: .succeeded(preimage: preimage) + ) + await restartedService.reconcile() + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertNil(submission.billingPeriod) + XCTAssertEqual(submission.paymentEndpointIdentifier, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": preimage, "type": PaykitPaymentProofKind.lightning.rawValue] + ) + let remainingProofs = await store.snapshot() + let processCallCount = await sdk.processCallCount() + XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(processCallCount, 1) + } + + func testMismatchedLightningPreimageIsNotSubmitted() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.completeLightningPayment(paymentHash: paymentHash, preimage: String(repeating: "01", count: 32)) + + let submissionCount = await sdk.submissionCount() + let persistedProof = await store.snapshot().first + XCTAssertEqual(submissionCount, 0) + XCTAssertNil(persistedProof?.proofData) + } + + func testExistingProofSuppressesDuplicateSubmission() async throws { + let proof = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + data: preimage + ) + let record = try paymentRequestRecord(paymentProofs: [proof]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 0) + XCTAssertTrue(remainingProofs.isEmpty) + } + + func testFailedLightningPaymentClearsCorrelation() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.failLightningPayment(paymentHash: paymentHash) + + let remainingProofs = await store.snapshot() + let submissionCount = await sdk.submissionCount() + XCTAssertTrue(remainingProofs.isEmpty) + XCTAssertEqual(submissionCount, 0) + } + + func testOnchainPaymentSubmitsTransactionIdForSelectedEndpoint() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await service.completeOnchainPayment(request, txid: txid) + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + + private func paymentProofService( + sdk: PaymentProofSdkMock, + store: PaymentProofMemoryStore, + lightningStatus: PaykitLightningPaymentProofStatus = .unknown + ) -> PaykitPaymentProofService { + PaykitPaymentProofService( + sdk: sdk, + store: store, + lightningPaymentLookup: PaymentProofLightningLookup(status: lightningStatus), + logInfo: { _ in }, + logWarning: { _ in } + ) + } + + private func paymentRequestRecord( + endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + paymentProofs: [PaymentProofRecord] = [] + ) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: counterparty, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: endpoints, + metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: paymentProofs, + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + } + + private func paymentProofRecord( + endpoint: String, + kind: PaykitPaymentProofKind, + data: String + ) throws -> PaymentProofRecord { + try PaymentProofRecord( + eventId: "750e8400-e29b-41d4-a716-446655440000", + outboundMessageId: nil, + outboundStatus: nil, + streamItemId: 2, + paymentReference: PaymentReference(text: "invoice-123"), + billingPeriod: nil, + paymentEndpointIdentifier: endpoint, + proof: PrivateJsonObject(text: "{\"data\":\"\(data)\",\"type\":\"\(kind.rawValue)\"}"), + recordedAt: "2027-01-15T08:01:00Z" + ) + } + + private func proofValues(_ text: String) throws -> [String: String] { + let data = try XCTUnwrap(text.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: String]) + } +} + +private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { + private var proofs: [PendingPaykitPaymentProof] = [] + + func load() -> [PendingPaykitPaymentProof] { + proofs + } + + func save(_ proofs: [PendingPaykitPaymentProof]) { + self.proofs = proofs + } + + func snapshot() -> [PendingPaykitPaymentProof] { + proofs + } +} + +private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp { + let status: PaykitLightningPaymentProofStatus + + func status(paymentHash _: String) async -> PaykitLightningPaymentProofStatus { + status + } +} + +private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { + private let identity: String + private var records: [PaymentRequestRecord] + private var submissions: [PaymentProofSubmission] = [] + private var shouldFailSubmission = false + private var privateMessageProcessCallCount = 0 + + init(identity: String, records: [PaymentRequestRecord]) { + self.identity = identity + self.records = records + } + + func identityStatus() -> IdentityStatus? { + IdentityStatus(publicKey: identity, liveSessionAvailable: true) + } + + func paymentRequests() -> [PaymentRequestRecord] { + records + } + + func processPendingPrivateMessages() -> [OutboundPrivateCounterpartySendReport] { + privateMessageProcessCallCount += 1 + return [] + } + + func submitPaymentProof( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + proof: PaymentProofSubmission + ) throws -> PaymentRequestRecord { + submissions.append(proof) + if shouldFailSubmission { + throw PaymentProofSdkMockError.submission + } + + guard let index = records.firstIndex(where: { + $0.counterparty == counterparty && + $0.counterpartyReceiverPath == counterpartyReceiverPath && + $0.paymentRequestId == paymentRequestId + }), let paymentReference = records[index].terms?.paymentReference else { + throw PaymentProofSdkMockError.requestMissing + } + records[index].paymentProofs.append(PaymentProofRecord( + eventId: UUID().uuidString, + outboundMessageId: 1, + outboundStatus: .pending, + streamItemId: nil, + paymentReference: paymentReference, + billingPeriod: proof.billingPeriod, + paymentEndpointIdentifier: proof.paymentEndpointIdentifier, + proof: proof.proof, + recordedAt: "2027-01-15T08:01:00Z" + )) + return records[index] + } + + func setSubmissionFailure(_ value: Bool) { + shouldFailSubmission = value + } + + func submissionCount() -> Int { + submissions.count + } + + func lastSubmission() -> PaymentProofSubmission? { + submissions.last + } + + func processCallCount() -> Int { + privateMessageProcessCallCount + } +} + +private enum PaymentProofSdkMockError: Error { + case requestMissing + case submission +} diff --git a/changelog.d/next/payment-proofs.added.md b/changelog.d/next/payment-proofs.added.md new file mode 100644 index 000000000..2a91491ee --- /dev/null +++ b/changelog.d/next/payment-proofs.added.md @@ -0,0 +1 @@ +Payments made from incoming private payment requests now send a payment proof back to the requester. From 8f25033ec27d08db7de969788a976941e932d898 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:19:23 -0500 Subject: [PATCH 2/8] chore: rename changelog fragment --- changelog.d/next/{payment-proofs.added.md => 683.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{payment-proofs.added.md => 683.added.md} (100%) diff --git a/changelog.d/next/payment-proofs.added.md b/changelog.d/next/683.added.md similarity index 100% rename from changelog.d/next/payment-proofs.added.md rename to changelog.d/next/683.added.md From b3a96f70eb0dd6847b88ad2113d56d3dea3e16b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:42:26 -0500 Subject: [PATCH 3/8] fix: harden paykit proof delivery --- .../Services/PaykitPaymentProofService.swift | 108 +++++++++++------- .../Views/Wallets/Send/LnurlPayConfirm.swift | 11 +- .../Wallets/Send/SendConfirmationView.swift | 16 +-- .../PaykitPaymentProofServiceTests.swift | 97 +++++++++++++++- 4 files changed, 170 insertions(+), 62 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 5e753d9d7..ffe502a52 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -93,7 +93,6 @@ actor PaykitPaymentProofService { private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void - private var proofs: [PendingPaykitPaymentProof]? init( sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared, @@ -130,7 +129,10 @@ actor PaykitPaymentProofService { var pendingProofs = try await loadProofs() pendingProofs.removeAll { - PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id + PubkyPublicKeyFormat.matches($0.identity, identity) && + $0.requestId == request.id && + $0.paymentIdentifier == nil && + $0.proofData == nil } pendingProofs.append(PendingPaykitPaymentProof( identity: identity, @@ -149,7 +151,12 @@ actor PaykitPaymentProofService { } var pendingProofs = try await loadProofs() - guard let index = pendingProofs.firstIndex(where: { $0.requestId == request.id && $0.kind == .lightning }) else { + guard let index = pendingProofs.lastIndex(where: { + $0.requestId == request.id && + $0.kind == .lightning && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) else { throw PaykitPaymentRequestError.requestUnavailable } pendingProofs[index].paymentIdentifier = paymentHash.lowercased() @@ -176,12 +183,10 @@ actor PaykitPaymentProofService { for index in indexes { pendingProofs[index].proofData = preimage.lowercased() } - try await persist(pendingProofs) - for index in indexes.reversed() { - await submit(pendingProofs[index]) - } + let completedProofs = indexes.map { pendingProofs[$0] } + await persistAndSubmit(completedProofs, allProofs: pendingProofs) } catch { - logWarning("Failed to persist a completed Paykit Lightning payment proof: \(error)") + logWarning("Failed to complete a Paykit Lightning payment proof: \(error)") } } @@ -193,13 +198,17 @@ actor PaykitPaymentProofService { do { var pendingProofs = try await loadProofs() - guard let index = pendingProofs.firstIndex(where: { $0.requestId == request.id && $0.kind == .onchain }) else { return } + guard let index = pendingProofs.lastIndex(where: { + $0.requestId == request.id && + $0.kind == .onchain && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) else { return } pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() - try await persist(pendingProofs) - await submit(pendingProofs[index]) + await persistAndSubmit([pendingProofs[index]], allProofs: pendingProofs) } catch { - logWarning("Failed to persist a completed Paykit on-chain payment proof: \(error)") + logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") } } @@ -209,8 +218,12 @@ actor PaykitPaymentProofService { } } - func cancel(_ request: PaykitPaymentRequest) async { - await removeProofs { $0.requestId == request.id } + func cancelPreparation(_ request: PaykitPaymentRequest) async { + await removeProofs { + $0.requestId == request.id && + $0.paymentIdentifier == nil && + $0.proofData == nil + } } func reconcile() async { @@ -260,51 +273,62 @@ actor PaykitPaymentProofService { }) else { return } let proofText = try Self.proofText(kind: pendingProof.kind, data: proofData) - if request.paymentProofs.contains(where: { + let isAlreadyQueued = request.paymentProofs.contains(where: { $0.billingPeriod == nil && $0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier && Self.proofValues($0.proof.exportText()) == Self.proofValues(proofText) - }) { - await removeProof(pendingProof) - return - } - - _ = try await sdk.submitPaymentProof( - counterparty: pendingProof.requestId.counterparty, - counterpartyReceiverPath: pendingProof.requestId.counterpartyReceiverPath, - paymentRequestId: pendingProof.requestId.paymentRequestId, - proof: Paykit.PaymentProofSubmission( - billingPeriod: nil, - paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, - proof: Paykit.PrivateJsonObject(text: proofText) + }) + + if !isAlreadyQueued { + _ = try await sdk.submitPaymentProof( + counterparty: pendingProof.requestId.counterparty, + counterpartyReceiverPath: pendingProof.requestId.counterpartyReceiverPath, + paymentRequestId: pendingProof.requestId.paymentRequestId, + proof: Paykit.PaymentProofSubmission( + billingPeriod: nil, + paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, + proof: Paykit.PrivateJsonObject(text: proofText) + ) ) - ) - await removeProof(pendingProof) - logInfo("Queued a Paykit payment proof for private delivery") - do { - _ = try await sdk.processPendingPrivateMessages() - } catch { - logWarning("Paykit payment proof remains queued for private delivery: \(error)") + logInfo("Queued a Paykit payment proof for private delivery") + do { + _ = try await sdk.processPendingPrivateMessages() + } catch { + logWarning("Paykit payment proof remains queued for private delivery: \(error)") + } } + await removeRequestProofs(pendingProof) } catch { logWarning("Failed to queue a Paykit payment proof: \(error)") } } private func loadProofs() async throws -> [PendingPaykitPaymentProof] { - if let proofs { return proofs } - let storedProofs = try await store.load() - proofs = storedProofs - return storedProofs + try await store.load() } private func persist(_ proofs: [PendingPaykitPaymentProof]) async throws { try await store.save(proofs) - self.proofs = proofs } - private func removeProof(_ proof: PendingPaykitPaymentProof) async { - await removeProofs { $0 == proof } + private func persistAndSubmit( + _ completedProofs: [PendingPaykitPaymentProof], + allProofs: [PendingPaykitPaymentProof] + ) async { + do { + try await persist(allProofs) + } catch { + logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") + } + for proof in completedProofs { + await submit(proof) + } + } + + private func removeRequestProofs(_ proof: PendingPaykitPaymentProof) async { + await removeProofs { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId + } } private func removeProofs(where shouldRemove: (PendingPaykitPaymentProof) -> Bool) async { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index e0a7ded67..339dcb1ae 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -201,15 +201,13 @@ struct LnurlPayConfirm: View { let contactPublicKey = contactPaymentContext?.publicKey let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var bolt11Invoice: String? + var lightningPaymentHash: String? var shouldCancelPaymentProof = false do { try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) if let incomingPaymentRequest { let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue - guard incomingPaymentRequest.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { - throw PaykitPaymentRequestError.requestUnavailable - } try await PaykitPaymentProofService.shared.prepare( request: incomingPaymentRequest, paymentEndpointIdentifier: endpointIdentifier, @@ -230,6 +228,7 @@ struct LnurlPayConfirm: View { let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) + lightningPaymentHash = paymentHash if let incomingPaymentRequest { try await PaykitPaymentProofService.shared.associateLightningPayment( incomingPaymentRequest, @@ -249,7 +248,6 @@ struct LnurlPayConfirm: View { } ) shouldCancelPaymentProof = false - await PaykitPaymentProofService.shared.reconcile() app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) Logger.info("LNURL payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) @@ -258,8 +256,11 @@ struct LnurlPayConfirm: View { shouldCancelPaymentProof = false return } catch { + if let lightningPaymentHash { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: lightningPaymentHash) + } if shouldCancelPaymentProof, let incomingPaymentRequest { - await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } Logger.error("LNURL payment failed: \(error)") diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 480538c31..ae7a4cb12 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -588,7 +588,7 @@ struct SendConfirmationView: View { try validateIncomingPaymentRequestContext(contactPaymentContext) try validateIncomingPaymentRequestAmounts(contactPaymentContext) if let incomingPaymentRequest { - let proof = try paymentProofPreparation(for: incomingPaymentRequest) + let proof = try paymentProofPreparation() try await PaykitPaymentProofService.shared.prepare( request: incomingPaymentRequest, paymentEndpointIdentifier: proof.endpointIdentifier, @@ -629,7 +629,6 @@ struct SendConfirmationView: View { } ) shouldCancelPaymentProof = false - await PaykitPaymentProofService.shared.reconcile() await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) @@ -638,6 +637,7 @@ struct SendConfirmationView: View { shouldCancelPaymentProof = false return } catch { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) throw error } } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { @@ -675,7 +675,7 @@ struct SendConfirmationView: View { } } catch { if shouldCancelPaymentProof, let incomingPaymentRequest { - await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } Logger.error("Payment failed: \(error)") @@ -692,24 +692,16 @@ struct SendConfirmationView: View { } } - private func paymentProofPreparation( - for request: PaykitPaymentRequest - ) throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { + private func paymentProofPreparation() throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { switch app.selectedWalletToPayFrom { case .lightning: let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue - guard request.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { - throw PaykitPaymentRequestError.requestUnavailable - } return (endpointIdentifier, .lightning) case .onchain: guard let address = app.scannedOnchainInvoice?.address else { throw PaykitPaymentRequestError.requestUnavailable } let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue - guard request.acceptedPaymentEndpointIdentifiers.contains(endpointIdentifier) else { - throw PaykitPaymentRequestError.requestUnavailable - } return (endpointIdentifier, .onchain) } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 2a466b74d..baeae2e59 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -143,6 +143,79 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testLightningRetryPreservesEarlierPaymentCorrelation() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: String(repeating: "aa", count: 32)) + + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 1) + XCTAssertTrue(remainingProofs.isEmpty) + } + + func testClearedStoreDoesNotRestoreCachedProofs() async throws { + let firstRecord = try paymentRequestRecord() + let firstRequest = try XCTUnwrap(PaykitPaymentRequest(record: firstRecord, now: Date())) + let secondRequestId = "550e8400-e29b-41d4-a716-446655440001" + let secondRecord = try paymentRequestRecord(paymentRequestId: secondRequestId) + let secondRequest = try XCTUnwrap(PaykitPaymentRequest(record: secondRecord, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [firstRecord, secondRecord]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: firstRequest, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + await store.clear() + try await service.prepare( + request: secondRequest, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + + let remainingProofs = await store.snapshot() + XCTAssertEqual(remainingProofs.count, 1) + XCTAssertEqual(remainingProofs.first?.requestId.paymentRequestId, secondRequestId) + } + + func testOnchainPaymentSubmitsWhenCompletedProofCannotBePersisted() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await store.failNextSave() + await service.completeOnchainPayment(request, txid: String(repeating: "ab", count: 32)) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 1) + XCTAssertTrue(remainingProofs.isEmpty) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, @@ -159,12 +232,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private func paymentRequestRecord( endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], - paymentProofs: [PaymentProofRecord] = [] + paymentProofs: [PaymentProofRecord] = [], + paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000" ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, counterpartyReceiverPath: PaykitReceiverPath.wallet, - paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", + paymentRequestId: paymentRequestId, localRole: .payer, state: .proposed, proposalStreamItemId: 1, @@ -220,15 +294,28 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] + private var shouldFailNextSave = false func load() -> [PendingPaykitPaymentProof] { proofs } - func save(_ proofs: [PendingPaykitPaymentProof]) { + func save(_ proofs: [PendingPaykitPaymentProof]) throws { + if shouldFailNextSave { + shouldFailNextSave = false + throw PaymentProofStoreMockError.save + } self.proofs = proofs } + func clear() { + proofs = [] + } + + func failNextSave() { + shouldFailNextSave = true + } + func snapshot() -> [PendingPaykitPaymentProof] { proofs } @@ -320,3 +407,7 @@ private enum PaymentProofSdkMockError: Error { case requestMissing case submission } + +private enum PaymentProofStoreMockError: Error { + case save +} From 6ba7229ba687baaf38d8b425b001a1a322f0b732 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 25 Aug 2026 10:38:55 -0500 Subject: [PATCH 4/8] fix: recover onchain paykit proof delivery --- .../Services/PaykitPaymentProofService.swift | 54 ++++++++++++++----- .../Wallets/Send/SendConfirmationView.swift | 10 +++- .../PaykitPaymentProofServiceTests.swift | 46 ++++++++++++++-- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index ffe502a52..32a28c90d 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -117,6 +117,28 @@ actor PaykitPaymentProofService { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind ) async throws { + let proof = try await pendingProof( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind + ) + + var pendingProofs = try await loadProofs() + pendingProofs.removeAll { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && + $0.requestId == request.id && + $0.paymentIdentifier == nil && + $0.proofData == nil + } + pendingProofs.append(proof) + try await persist(pendingProofs) + } + + private func pendingProof( + request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + kind: PaykitPaymentProofKind + ) async throws -> PendingPaykitPaymentProof { guard request.acceptedPaymentEndpointIdentifiers.contains(paymentEndpointIdentifier), Self.endpoint(paymentEndpointIdentifier, supports: kind), let identityStatus = try await sdk.identityStatus(), @@ -127,22 +149,14 @@ actor PaykitPaymentProofService { throw PaykitPaymentRequestError.requestUnavailable } - var pendingProofs = try await loadProofs() - pendingProofs.removeAll { - PubkyPublicKeyFormat.matches($0.identity, identity) && - $0.requestId == request.id && - $0.paymentIdentifier == nil && - $0.proofData == nil - } - pendingProofs.append(PendingPaykitPaymentProof( + return PendingPaykitPaymentProof( identity: identity, requestId: request.id, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, paymentIdentifier: nil, proofData: nil - )) - try await persist(pendingProofs) + ) } func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { @@ -190,7 +204,11 @@ actor PaykitPaymentProofService { } } - func completeOnchainPayment(_ request: PaykitPaymentRequest, txid: String) async { + func completeOnchainPayment( + _ request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String + ) async { guard Self.isHex(txid, byteCount: 32) else { logWarning("Ignored a Paykit on-chain proof with an invalid transaction id") return @@ -208,7 +226,19 @@ actor PaykitPaymentProofService { pendingProofs[index].proofData = txid.lowercased() await persistAndSubmit([pendingProofs[index]], allProofs: pendingProofs) } catch { - logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") + logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") + do { + var proof = try await pendingProof( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: .onchain + ) + proof.paymentIdentifier = txid.lowercased() + proof.proofData = txid.lowercased() + await submit(proof) + } catch { + logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") + } } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index ae7a4cb12..bd762b311 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -583,6 +583,7 @@ struct SendConfirmationView: View { let contactPublicKey = contactPaymentContext?.publicKey let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest var shouldCancelPaymentProof = false + var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind)? do { try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -594,6 +595,7 @@ struct SendConfirmationView: View { paymentEndpointIdentifier: proof.endpointIdentifier, kind: proof.kind ) + preparedPaymentProof = proof shouldCancelPaymentProof = true } try await prepareIncomingPaymentRequest() @@ -645,8 +647,12 @@ struct SendConfirmationView: View { let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) shouldCancelPaymentProof = false - if let incomingPaymentRequest { - await PaykitPaymentProofService.shared.completeOnchainPayment(incomingPaymentRequest, txid: txid) + if let incomingPaymentRequest, let preparedPaymentProof { + await PaykitPaymentProofService.shared.completeOnchainPayment( + incomingPaymentRequest, + txid: txid, + paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier + ) } // Create pre-activity metadata for tags and activity address diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index baeae2e59..0be0585e4 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -130,7 +130,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) - await service.completeOnchainPayment(request, txid: txid) + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) @@ -208,7 +208,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) await store.failNextSave() - await service.completeOnchainPayment(request, txid: String(repeating: "ab", count: 32)) + await service.completeOnchainPayment( + request, + txid: String(repeating: "ab", count: 32), + paymentEndpointIdentifier: endpoint + ) let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() @@ -216,6 +220,30 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testOnchainPaymentSubmitsWhenPreparedProofCannotBeLoaded() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await store.failNextLoad() + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) + XCTAssertEqual( + try proofValues(submission.proof.exportText()), + ["data": txid, "type": PaykitPaymentProofKind.onchain.rawValue] + ) + let remainingProofs = await store.snapshot() + XCTAssertTrue(remainingProofs.isEmpty) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, @@ -294,10 +322,15 @@ final class PaykitPaymentProofServiceTests: XCTestCase { private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] + private var shouldFailNextLoad = false private var shouldFailNextSave = false - func load() -> [PendingPaykitPaymentProof] { - proofs + func load() throws -> [PendingPaykitPaymentProof] { + if shouldFailNextLoad { + shouldFailNextLoad = false + throw PaymentProofStoreMockError.load + } + return proofs } func save(_ proofs: [PendingPaykitPaymentProof]) throws { @@ -316,6 +349,10 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { shouldFailNextSave = true } + func failNextLoad() { + shouldFailNextLoad = true + } + func snapshot() -> [PendingPaykitPaymentProof] { proofs } @@ -409,5 +446,6 @@ private enum PaymentProofSdkMockError: Error { } private enum PaymentProofStoreMockError: Error { + case load case save } From 833ed4f90c61d5a5f800493017e1f6014d6a024b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 13:15:13 -0500 Subject: [PATCH 5/8] fix: harden payment proof recovery --- .../Services/PaykitPaymentProofService.swift | 18 +++++++++++++++--- .../PaykitPaymentProofServiceTests.swift | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 32a28c90d..01c2282eb 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -29,10 +29,20 @@ struct PaykitPaymentProofStore: PaykitPaymentProofStoring { func load() async throws -> [PendingPaykitPaymentProof] { guard let data = try Keychain.load(key: .paykitPendingPaymentProofs) else { return [] } - return try JSONDecoder().decode(State.self, from: data).proofs + do { + return try JSONDecoder().decode(State.self, from: data).proofs + } catch { + Logger.warn("Discarding invalid pending Paykit payment proof state: \(error)", context: "PaykitPaymentProof") + try? Keychain.delete(key: .paykitPendingPaymentProofs) + return [] + } } func save(_ proofs: [PendingPaykitPaymentProof]) async throws { + guard !proofs.isEmpty else { + try Keychain.delete(key: .paykitPendingPaymentProofs) + return + } try Keychain.upsert( key: .paykitPendingPaymentProofs, data: JSONEncoder().encode(State(proofs: proofs)) @@ -258,16 +268,18 @@ actor PaykitPaymentProofService { func reconcile() async { do { + let pendingProofs = try await loadProofs() + guard !pendingProofs.isEmpty else { return } guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { return } - let pendingProofs = try await loadProofs().filter { + let identityProofs = pendingProofs.filter { PubkyPublicKeyFormat.matches($0.identity, identity) } - for proof in pendingProofs { + for proof in identityProofs { if proof.proofData != nil { await submit(proof) continue diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 0be0585e4..dea6af66d 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -244,6 +244,17 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testReconcileWithoutPendingProofsSkipsSdk() async { + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: []) + let service = paymentProofService(sdk: sdk, store: store) + + await service.reconcile() + + let identityStatusCallCount = await sdk.identityStatusCallCount() + XCTAssertEqual(identityStatusCallCount, 0) + } + private func paymentProofService( sdk: PaymentProofSdkMock, store: PaymentProofMemoryStore, @@ -372,6 +383,7 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { private var submissions: [PaymentProofSubmission] = [] private var shouldFailSubmission = false private var privateMessageProcessCallCount = 0 + private var identityStatusCalls = 0 init(identity: String, records: [PaymentRequestRecord]) { self.identity = identity @@ -379,7 +391,8 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { } func identityStatus() -> IdentityStatus? { - IdentityStatus(publicKey: identity, liveSessionAvailable: true) + identityStatusCalls += 1 + return IdentityStatus(publicKey: identity, liveSessionAvailable: true) } func paymentRequests() -> [PaymentRequestRecord] { @@ -438,6 +451,10 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { func processCallCount() -> Int { privateMessageProcessCallCount } + + func identityStatusCallCount() -> Int { + identityStatusCalls + } } private enum PaymentProofSdkMockError: Error { From e66771b403c38056447f2e4a0652685468fd97bb Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 07:32:57 -0500 Subject: [PATCH 6/8] fix: address payment proof review --- .../Services/PaykitPaymentProofService.swift | 51 ++++++-- .../PaymentRequests/PaymentRequestsView.swift | 8 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 5 + .../Wallets/Send/SendConfirmationView.swift | 7 ++ .../PaykitPaymentProofServiceTests.swift | 116 +++++++++++++++++- 5 files changed, 168 insertions(+), 19 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 01c2282eb..3d4980ab8 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -234,21 +234,20 @@ actor PaykitPaymentProofService { }) else { return } pendingProofs[index].paymentIdentifier = txid.lowercased() pendingProofs[index].proofData = txid.lowercased() - await persistAndSubmit([pendingProofs[index]], allProofs: pendingProofs) - } catch { - logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") + let completedProof = pendingProofs[index] do { - var proof = try await pendingProof( - request: request, - paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: .onchain - ) - proof.paymentIdentifier = txid.lowercased() - proof.proofData = txid.lowercased() - await submit(proof) + try await persist(pendingProofs) } catch { - logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") + logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") } + submitInBackground(completedProof) + } catch { + logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") + submitOnchainPaymentInBackground( + request, + txid: txid, + paymentEndpointIdentifier: paymentEndpointIdentifier + ) } } @@ -367,6 +366,34 @@ actor PaykitPaymentProofService { } } + private func submitInBackground(_ proof: PendingPaykitPaymentProof) { + Task { [weak self] in + await self?.submit(proof) + } + } + + private func submitOnchainPaymentInBackground( + _ request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String + ) { + Task { [weak self] in + guard let self else { return } + do { + var proof = try await pendingProof( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: .onchain + ) + proof.paymentIdentifier = txid.lowercased() + proof.proofData = txid.lowercased() + await submit(proof) + } catch { + logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") + } + } + } + private func removeRequestProofs(_ proof: PendingPaykitPaymentProof) async { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index e37cb7592..17a5294af 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -63,14 +63,15 @@ struct PaymentRequestCard: View { } .padding(16) } else if onPay != nil || onReject != nil { - HStack(spacing: 12) { + HStack(spacing: 16) { CustomButton( title: t("wallet__payment_request_dismiss"), variant: .secondary, size: .small, icon: Image("x-mark").resizable().frame(width: 16, height: 16), isDisabled: isActionDisabled, - isLoading: isRejecting + isLoading: isRejecting, + shouldExpand: true ) { guard !isRejecting else { return } isRejecting = true @@ -83,7 +84,8 @@ struct PaymentRequestCard: View { title: t("common__pay"), size: .small, icon: Image("coins").resizable().frame(width: 16, height: 16), - isDisabled: isActionDisabled || isRejecting + isDisabled: isActionDisabled || isRejecting, + shouldExpand: true ) { onPay?() } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 339dcb1ae..fe576042a 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -255,6 +255,11 @@ struct LnurlPayConfirm: View { // onTimeout callback already navigated to .pending; suppress throw shouldCancelPaymentProof = false return + } catch is CancellationError { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } + return } catch { if let lightningPaymentHash { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: lightningPaymentHash) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index bd762b311..b0b47a42e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -638,6 +638,8 @@ struct SendConfirmationView: View { // onTimeout callback already navigated to .pending; suppress throw shouldCancelPaymentProof = false return + } catch is CancellationError { + throw CancellationError() } catch { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) throw error @@ -679,6 +681,11 @@ struct SendConfirmationView: View { domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"] ) } + } catch is CancellationError { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } + return } catch { if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index dea6af66d..336e83490 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -120,17 +120,50 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(submissionCount, 0) } + func testCanceledLightningWaitPreservesCorrelationForLaterSettlement() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.cancelPreparation(request) + + let correlatedProof = await store.snapshot().first + XCTAssertEqual(correlatedProof?.paymentIdentifier, paymentHash) + + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 1) + XCTAssertTrue(remainingProofs.isEmpty) + } + func testOnchainPaymentSubmitsTransactionIdForSelectedEndpoint() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let store = PaymentProofMemoryStore() + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) @@ -143,6 +176,34 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertTrue(remainingProofs.isEmpty) } + func testOnchainCompletionDoesNotWaitForProofDelivery() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + let completion = expectation(description: "On-chain proof state persisted") + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await sdk.suspendSubmission() + let completionTask = Task { + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + completion.fulfill() + } + + await fulfillment(of: [completion], timeout: 1) + await sdk.waitForSubmissionStart() + + let persistedProof = await store.snapshot().first + XCTAssertEqual(persistedProof?.paymentIdentifier, txid) + XCTAssertEqual(persistedProof?.proofData, txid) + + await sdk.resumeSubmission() + await completionTask.value + } + func testLightningRetryPreservesEarlierPaymentCorrelation() async throws { let record = try paymentRequestRecord() let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -202,7 +263,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let store = PaymentProofMemoryStore() + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) @@ -213,6 +279,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { txid: String(repeating: "ab", count: 32), paymentEndpointIdentifier: endpoint ) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) let submissionCount = await sdk.submissionCount() let remainingProofs = await store.snapshot() @@ -224,7 +292,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) - let store = PaymentProofMemoryStore() + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) @@ -232,6 +305,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) await store.failNextLoad() await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) @@ -335,6 +410,11 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { private var proofs: [PendingPaykitPaymentProof] = [] private var shouldFailNextLoad = false private var shouldFailNextSave = false + private let onSave: @Sendable ([PendingPaykitPaymentProof]) -> Void + + init(onSave: @escaping @Sendable ([PendingPaykitPaymentProof]) -> Void = { _ in }) { + self.onSave = onSave + } func load() throws -> [PendingPaykitPaymentProof] { if shouldFailNextLoad { @@ -350,6 +430,7 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { throw PaymentProofStoreMockError.save } self.proofs = proofs + onSave(proofs) } func clear() { @@ -384,6 +465,9 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { private var shouldFailSubmission = false private var privateMessageProcessCallCount = 0 private var identityStatusCalls = 0 + private var shouldSuspendSubmission = false + private var submissionContinuation: CheckedContinuation? + private var submissionStartContinuations: [CheckedContinuation] = [] init(identity: String, records: [PaymentRequestRecord]) { self.identity = identity @@ -409,8 +493,15 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { counterpartyReceiverPath: String, paymentRequestId: String, proof: PaymentProofSubmission - ) throws -> PaymentRequestRecord { + ) async throws -> PaymentRequestRecord { submissions.append(proof) + submissionStartContinuations.forEach { $0.resume() } + submissionStartContinuations.removeAll() + if shouldSuspendSubmission { + await withCheckedContinuation { continuation in + submissionContinuation = continuation + } + } if shouldFailSubmission { throw PaymentProofSdkMockError.submission } @@ -440,6 +531,23 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { shouldFailSubmission = value } + func suspendSubmission() { + shouldSuspendSubmission = true + } + + func resumeSubmission() { + shouldSuspendSubmission = false + submissionContinuation?.resume() + submissionContinuation = nil + } + + func waitForSubmissionStart() async { + guard submissions.isEmpty else { return } + await withCheckedContinuation { continuation in + submissionStartContinuations.append(continuation) + } + } + func submissionCount() -> Int { submissions.count } From 6bcff553e0c75f712a89b62515fad144eac18389 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 11:36:48 -0500 Subject: [PATCH 7/8] fix: lock incoming request amounts --- .../Utilities/PaymentNavigationHelper.swift | 23 +++--- .../PaymentNavigationHelperTests.swift | 80 ++++++++++++++++++- 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 60e038638..82561e071 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -205,21 +205,20 @@ struct PaymentNavigationHelper { return nil } + if app.contactPaymentContext?.incomingPaymentRequest != nil { + switch route { + case .quickpay, .amount: + return app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + case .lnurlPayAmount: + return .lnurlPayConfirm + default: + return route + } + } + switch route { case .quickpay: return confirmRouteAfterQuickPayCap(app: app) - case .confirm: - if let invoice = app.scannedLightningInvoice { - return invoice.amountSatoshis == 0 ? .amount : .confirm - } - return route - case .amount: - if app.contactPaymentContext?.incomingPaymentRequest != nil, - let invoice = app.scannedOnchainInvoice - { - return invoice.amountSatoshis == 0 ? .amount : .confirm - } - return route default: return route } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index adf098be2..b304b7f05 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import Paykit import XCTest @MainActor @@ -149,6 +150,37 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + func testIncomingRequestWithAmountlessLightningInvoiceOpensConfirm() throws { + let app = appWithInvoice(paymentHash: "incoming", amountSatoshis: 0) + app.contactPaymentContext = try ContactPaymentContext( + publicKey: "pubkycontact", + incomingPaymentRequest: incomingPaymentRequest( + endpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + ) + ) + + XCTAssertEqual(contactPaymentRoute(for: app), .confirm) + } + + func testIncomingRequestWithAmountlessOnchainInvoiceOpensConfirm() throws { + let app = AppViewModel() + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qexample", + amountSatoshis: 0, + label: nil, + message: nil, + params: nil + ) + app.contactPaymentContext = try ContactPaymentContext( + publicKey: "pubkycontact", + incomingPaymentRequest: incomingPaymentRequest( + endpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + ) + ) + + XCTAssertEqual(contactPaymentRoute(for: app), .confirm) + } + func testReserveRaceFallsBackToConfirm() { XCTAssertEqual( PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: appWithEligibleInvoice), @@ -184,16 +216,25 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + private func contactPaymentRoute(for app: AppViewModel) -> SendRoute? { + PaymentNavigationHelper.contactPaymentRoute( + app: app, + currency: CurrencyViewModel(), + settings: settings, + spendStore: spendStore + ) + } + private var appWithEligibleInvoice: AppViewModel { appWithInvoice(paymentHash: "") } - private func appWithInvoice(paymentHash: String) -> AppViewModel { + private func appWithInvoice(paymentHash: String, amountSatoshis: UInt64 = 1000) -> AppViewModel { let app = AppViewModel() app.scannedLightningInvoice = LightningInvoice( bolt11: "test-invoice", paymentHash: paymentHash.hexaData, - amountSatoshis: 1000, + amountSatoshis: amountSatoshis, timestampSeconds: 0, expirySeconds: 0, isExpired: false, @@ -204,6 +245,41 @@ final class PaymentNavigationHelperTests: XCTestCase { return app } + private func incomingPaymentRequest(endpointIdentifier: String) throws -> PaykitPaymentRequest { + let record = try PaymentRequestRecord( + counterparty: "pubkycontact", + counterpartyReceiverPath: "bitkit/wallet", + paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: [endpointIdentifier], + metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + return try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date(timeIntervalSince1970: 0))) + } + private var usdRate: FxRate { FxRate( symbol: "BTCUSD", From 489b1e61d4710be22ed5fca57fe6694e5827f76c Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 12:15:56 -0500 Subject: [PATCH 8/8] fix: prove hardware request payments --- Bitkit/Components/Button/Button.swift | 3 +- .../Button/SecondaryButtonView.swift | 3 +- Bitkit/ViewModels/HwFundingSigner.swift | 7 ++- .../PaymentRequests/PaymentRequestsView.swift | 2 - .../Views/Wallets/Send/HwSendSignView.swift | 16 ++++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 43 ++++++++++++++++++- BitkitTests/HwFundingSignerTests.swift | 8 +++- 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 61582d0bd..61b7a06da 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -167,7 +167,8 @@ struct CustomButton: View { icon: icon, isDisabled: effectiveIsDisabled, isPressed: isPressed, - isLoading: isLoading + isLoading: isLoading, + shouldExpand: shouldExpand )) case .tertiary: AnyView(TertiaryButtonView( diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index c146b9bd5..3fa8dea0d 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -7,6 +7,7 @@ struct SecondaryButtonView: View { let isDisabled: Bool let isPressed: Bool var isLoading: Bool = false + let shouldExpand: Bool var body: some View { HStack(spacing: 8) { @@ -24,7 +25,7 @@ struct SecondaryButtonView: View { BodySSBText(title, textColor: textColor) } } - .frame(maxWidth: size == .large ? .infinity : nil) + .frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil) .frame(height: buttonHeight) .padding(.horizontal, 16) .background(isPressed ? Color.white10 : Color.clear) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 878147d48..215e3ecc1 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -427,7 +427,8 @@ final class HwSendCoordinator { address: String, sats: UInt64, satsPerVByte: UInt64, - beforeBroadcast: @escaping () async throws -> Void = {} + beforeBroadcast: @escaping () async throws -> Void = {}, + afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in } ) async throws -> HwFundingBroadcastResult { guard let walletId else { throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") @@ -464,7 +465,9 @@ final class HwSendCoordinator { isBroadcastUnresolved = true do { - return try await signer.broadcastSignedFunding(signed) + let result = try await signer.broadcastSignedFunding(signed) + await afterBroadcast(result) + return result } catch { let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 17a5294af..3e42ac6da 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -78,7 +78,6 @@ struct PaymentRequestCard: View { await onReject?() isRejecting = false } - .frame(maxWidth: .infinity) CustomButton( title: t("common__pay"), @@ -89,7 +88,6 @@ struct PaymentRequestCard: View { ) { onPay?() } - .frame(maxWidth: .infinity) } .padding(16) .background(Color.gray5) diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 4f5976579..1b72c89ff 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -10,6 +10,8 @@ struct HwSendSignView: View { @Binding var navigationPath: [SendRoute] let hwSend: HwSendCoordinator let prepareContactPayment: () async throws -> Void + let completeContactPayment: (String) async -> Void + let cancelContactPayment: () async -> Void @State private var signingTask: Task? @State private var passphraseTask: Task? @@ -109,7 +111,10 @@ struct HwSendSignView: View { address: invoice.address, sats: amount, satsPerVByte: UInt64(feeRate), - beforeBroadcast: prepareContactPayment + beforeBroadcast: prepareContactPayment, + afterBroadcast: { result in + await completeContactPayment(result.txId) + } ) await recordSentPayment( result, @@ -121,17 +126,26 @@ struct HwSendSignView: View { hwSend.completeBroadcast() navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } catch is CancellationError { + await cancelContactPaymentIfBroadcastIsResolved() return } catch is HwPassphraseError { + await cancelContactPaymentIfBroadcastIsResolved() hwSend.requestPassphrase() } catch let error as HwTransferError { + await cancelContactPaymentIfBroadcastIsResolved() app.toast(error) } catch { + await cancelContactPaymentIfBroadcastIsResolved() showHardwareError(error) } } } + private func cancelContactPaymentIfBroadcastIsResolved() async { + guard !hwSend.isBroadcastUnresolved else { return } + await cancelContactPayment() + } + private func reconnectWithPassphrase(_ passphrase: String) { guard passphraseTask == nil else { return } passphraseTask = Task { @MainActor in diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index e4254fe07..6fb64c8ae 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -608,7 +608,9 @@ struct SendSheet: View { HwSendSignView( navigationPath: $navigationPath, hwSend: hwSend, - prepareContactPayment: prepareIncomingPaymentRequest + prepareContactPayment: prepareHardwareContactPayment, + completeContactPayment: completeHardwareContactPayment, + cancelContactPayment: cancelHardwareContactPayment ) case .feeRate: SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) @@ -682,6 +684,45 @@ struct SendSheet: View { } } + private func prepareHardwareContactPayment() async throws { + guard let request = app.contactPaymentContext?.incomingPaymentRequest, + let address = app.scannedOnchainInvoice?.address + else { + try await prepareIncomingPaymentRequest() + return + } + + let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentEndpointIdentifier: endpointIdentifier, + kind: .onchain + ) + do { + try await prepareIncomingPaymentRequest() + } catch { + await PaykitPaymentProofService.shared.cancelPreparation(request) + throw error + } + } + + private func completeHardwareContactPayment(txid: String) async { + guard let request = app.contactPaymentContext?.incomingPaymentRequest, + let address = app.scannedOnchainInvoice?.address + else { return } + + await PaykitPaymentProofService.shared.completeOnchainPayment( + request, + txid: txid, + paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue + ) + } + + private func cancelHardwareContactPayment() async { + guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return } + await PaykitPaymentProofService.shared.cancelPreparation(request) + } + private func replaceQuickPay(with route: SendRoute) { app.resetQuickPay() let next = PaymentNavigationHelper.replacingQuickPay(in: navigationPath, root: currentRoot, with: route) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index cff215de9..d108bda4f 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -90,6 +90,7 @@ final class HwFundingSignerTests: XCTestCase { } ) var beforeBroadcastCalls = 0 + var completedTransactionIds: [String] = [] funding.broadcastError = error await assertThrowsAsync { @@ -98,7 +99,8 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcast: { beforeBroadcastCalls += 1 } + beforeBroadcast: { beforeBroadcastCalls += 1 }, + afterBroadcast: { completedTransactionIds.append($0.txId) } ) } @@ -111,7 +113,8 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcast: { beforeBroadcastCalls += 1 } + beforeBroadcast: { beforeBroadcastCalls += 1 }, + afterBroadcast: { completedTransactionIds.append($0.txId) } ) XCTAssertEqual(funding.composeCalls.count, 1) @@ -119,6 +122,7 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 2) XCTAssertEqual(funding.broadcastTransactions, [funding.signedTx.serializedTx, funding.signedTx.serializedTx]) XCTAssertEqual(beforeBroadcastCalls, 1) + XCTAssertEqual(completedTransactionIds, [funding.broadcastTxId]) } // MARK: - Availability