From 5739c5333229aee878b374f13591ae0a870bcb69 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:17:27 -0500 Subject: [PATCH 1/4] 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 | 49 +++ .../PaykitPaymentProofServiceTests.swift | 322 +++++++++++++++ changelog.d/next/payment-proofs.added.md | 1 + 9 files changed, 795 insertions(+), 1 deletion(-) 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 ff455107e..9c5c18dab 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -770,6 +770,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 41c385f5e..fd2fe711d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1039,7 +1039,13 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, _, _): + case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, _): + Task { + await PaykitPaymentProofService.shared.completeLightningPayment( + paymentHash: paymentHash, + preimage: paymentPreimage + ) + } let hash = paymentId ?? paymentHash if pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) @@ -1053,6 +1059,9 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash + if let paymentHash = paymentHash ?? paymentId { + Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } + } if let hash, pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index ec144e3b7..34e1ef1db 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: paymentHash, 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 d88ab040e..b6fff1e70 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -493,10 +493,21 @@ struct SendConfirmationView: View { var createdMetadataPaymentId: String? = nil let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey + let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + var shouldCancelPaymentProof = false do { 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) @@ -507,6 +518,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) @@ -523,11 +540,14 @@ struct SendConfirmationView: View { navigationPath.append(.pending(paymentHash: paymentHash, 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 @@ -536,6 +556,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) @@ -562,6 +586,9 @@ struct SendConfirmationView: View { ) } } catch { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + } Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { @@ -577,6 +604,28 @@ struct SendConfirmationView: View { } } + 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 { guard let context, let request = context.incomingPaymentRequest else { return } guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } 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 75335b800bba12366651525bc833b8492f03fb00 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:19:23 -0500 Subject: [PATCH 2/4] 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 c5d04c702dcf5212f985d1f6838eeabb13750257 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 24 Aug 2026 10:42:26 -0500 Subject: [PATCH 3/4] 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 34e1ef1db..f17c4cc3e 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 b6fff1e70..7f55a6b25 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -500,7 +500,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, @@ -541,7 +541,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)) @@ -550,6 +549,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 { @@ -587,7 +587,7 @@ struct SendConfirmationView: View { } } catch { if shouldCancelPaymentProof, let incomingPaymentRequest { - await PaykitPaymentProofService.shared.cancel(incomingPaymentRequest) + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } Logger.error("Payment failed: \(error)") @@ -604,24 +604,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 fee86019a491796236bb2a5077c84cf4fe6b3bfc Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 25 Aug 2026 10:38:55 -0500 Subject: [PATCH 4/4] 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 7f55a6b25..09d8d8f27 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -495,6 +495,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) @@ -506,6 +507,7 @@ struct SendConfirmationView: View { paymentEndpointIdentifier: proof.endpointIdentifier, kind: proof.kind ) + preparedPaymentProof = proof shouldCancelPaymentProof = true } try await prepareIncomingPaymentRequest() @@ -557,8 +559,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 }