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..32a28c90d --- /dev/null +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -0,0 +1,422 @@ +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 + + 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 { + 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(), + identityStatus.liveSessionAvailable, + let publicKey = identityStatus.publicKey, + let identity = PubkyPublicKeyFormat.normalized(publicKey) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + + return PendingPaykitPaymentProof( + identity: identity, + requestId: request.id, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind, + paymentIdentifier: nil, + proofData: nil + ) + } + + 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.lastIndex(where: { + $0.requestId == request.id && + $0.kind == .lightning && + $0.paymentIdentifier == nil && + $0.proofData == nil + }) 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() + } + let completedProofs = indexes.map { pendingProofs[$0] } + await persistAndSubmit(completedProofs, allProofs: pendingProofs) + } catch { + logWarning("Failed to complete a Paykit Lightning payment proof: \(error)") + } + } + + 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 + } + + do { + var pendingProofs = try await loadProofs() + 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() + await persistAndSubmit([pendingProofs[index]], allProofs: pendingProofs) + } catch { + 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)") + } + } + } + + func failLightningPayment(paymentHash: String) async { + await removeProofs { + $0.kind == .lightning && $0.paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame + } + } + + func cancelPreparation(_ request: PaykitPaymentRequest) async { + await removeProofs { + $0.requestId == request.id && + $0.paymentIdentifier == nil && + $0.proofData == nil + } + } + + 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) + let isAlreadyQueued = request.paymentProofs.contains(where: { + $0.billingPeriod == nil && + $0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier && + Self.proofValues($0.proof.exportText()) == Self.proofValues(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) + ) + ) + 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] { + try await store.load() + } + + private func persist(_ proofs: [PendingPaykitPaymentProof]) async throws { + try await store.save(proofs) + } + + 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 { + 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..f17c4cc3e 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -199,10 +199,22 @@ 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 lightningPaymentHash: String? + var shouldCancelPaymentProof = false do { try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) + if let incomingPaymentRequest { + let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + try await PaykitPaymentProofService.shared.prepare( + request: incomingPaymentRequest, + paymentEndpointIdentifier: endpointIdentifier, + kind: .lightning + ) + shouldCancelPaymentProof = true + } try await prepareIncomingPaymentRequest() try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) @@ -216,6 +228,13 @@ 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, + 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 +247,21 @@ struct LnurlPayConfirm: View { navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .lnurlPayConfirm, paymentRequest: bolt11)) } ) + shouldCancelPaymentProof = false 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 let lightningPaymentHash { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: lightningPaymentHash) + } + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(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..09d8d8f27 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -493,10 +493,23 @@ struct SendConfirmationView: View { var createdMetadataPaymentId: String? = nil let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey + let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + var shouldCancelPaymentProof = false + var preparedPaymentProof: (endpointIdentifier: String, kind: PaykitPaymentProofKind)? do { try validateIncomingPaymentRequestContext(contactPaymentContext) try validateIncomingPaymentRequestAmounts(contactPaymentContext) + if let incomingPaymentRequest { + let proof = try paymentProofPreparation() + try await PaykitPaymentProofService.shared.prepare( + request: incomingPaymentRequest, + paymentEndpointIdentifier: proof.endpointIdentifier, + kind: proof.kind + ) + preparedPaymentProof = proof + shouldCancelPaymentProof = true + } try await prepareIncomingPaymentRequest() try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -507,6 +520,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,19 +542,30 @@ struct SendConfirmationView: View { navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .confirm, paymentRequest: invoice.bolt11)) } ) + shouldCancelPaymentProof = false 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 { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) throw error } } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { 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, let preparedPaymentProof { + await PaykitPaymentProofService.shared.completeOnchainPayment( + incomingPaymentRequest, + txid: txid, + paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier + ) + } // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) @@ -562,6 +592,9 @@ struct SendConfirmationView: View { ) } } catch { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { @@ -577,6 +610,20 @@ struct SendConfirmationView: View { } } + private func paymentProofPreparation() throws -> (endpointIdentifier: String, kind: PaykitPaymentProofKind) { + switch app.selectedWalletToPayFrom { + case .lightning: + let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + return (endpointIdentifier, .lightning) + case .onchain: + guard let address = app.scannedOnchainInvoice?.address else { + throw PaykitPaymentRequestError.requestUnavailable + } + let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + 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..0be0585e4 --- /dev/null +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -0,0 +1,451 @@ +@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, 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) + } + + 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), + paymentEndpointIdentifier: endpoint + ) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 1) + 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, + 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] = [], + paymentRequestId: String = "550e8400-e29b-41d4-a716-446655440000" + ) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: counterparty, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + paymentRequestId: paymentRequestId, + 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] = [] + private var shouldFailNextLoad = false + private var shouldFailNextSave = false + + func load() throws -> [PendingPaykitPaymentProof] { + if shouldFailNextLoad { + shouldFailNextLoad = false + throw PaymentProofStoreMockError.load + } + return proofs + } + + func save(_ proofs: [PendingPaykitPaymentProof]) throws { + if shouldFailNextSave { + shouldFailNextSave = false + throw PaymentProofStoreMockError.save + } + self.proofs = proofs + } + + func clear() { + proofs = [] + } + + func failNextSave() { + shouldFailNextSave = true + } + + func failNextLoad() { + shouldFailNextLoad = true + } + + 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 +} + +private enum PaymentProofStoreMockError: Error { + case load + case save +} diff --git a/changelog.d/next/683.added.md b/changelog.d/next/683.added.md new file mode 100644 index 000000000..2a91491ee --- /dev/null +++ b/changelog.d/next/683.added.md @@ -0,0 +1 @@ +Payments made from incoming private payment requests now send a payment proof back to the requester.