diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 35b222c27..ab8f05a7f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -773,6 +773,7 @@ struct AppScene: View { return false } + await PaykitPaymentProofService.shared.reconcile() let previousRequests = paykitPaymentRequestManager.pendingRequests await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) await paykitPaymentRequestManager.refresh() diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 61582d0bd..61b7a06da 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -167,7 +167,8 @@ struct CustomButton: View { icon: icon, isDisabled: effectiveIsDisabled, isPressed: isPressed, - isLoading: isLoading + isLoading: isLoading, + shouldExpand: shouldExpand )) case .tertiary: AnyView(TertiaryButtonView( diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index c146b9bd5..3fa8dea0d 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -7,6 +7,7 @@ struct SecondaryButtonView: View { let isDisabled: Bool let isPressed: Bool var isLoading: Bool = false + let shouldExpand: Bool var body: some View { HStack(spacing: 8) { @@ -24,7 +25,7 @@ struct SecondaryButtonView: View { BodySSBText(title, textColor: textColor) } } - .frame(maxWidth: size == .large ? .infinity : nil) + .frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil) .frame(height: buttonHeight) .padding(.horizontal, 16) .background(isPressed ? Color.white10 : Color.clear) diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift new file mode 100644 index 000000000..3d4980ab8 --- /dev/null +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -0,0 +1,461 @@ +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 [] } + do { + return try JSONDecoder().decode(State.self, from: data).proofs + } catch { + Logger.warn("Discarding invalid pending Paykit payment proof state: \(error)", context: "PaykitPaymentProof") + try? Keychain.delete(key: .paykitPendingPaymentProofs) + return [] + } + } + + func save(_ proofs: [PendingPaykitPaymentProof]) async throws { + guard !proofs.isEmpty else { + try Keychain.delete(key: .paykitPendingPaymentProofs) + return + } + try Keychain.upsert( + key: .paykitPendingPaymentProofs, + data: JSONEncoder().encode(State(proofs: proofs)) + ) + } +} + +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() + let completedProof = pendingProofs[index] + do { + try await persist(pendingProofs) + } catch { + logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)") + } + submitInBackground(completedProof) + } catch { + logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") + submitOnchainPaymentInBackground( + request, + txid: txid, + paymentEndpointIdentifier: paymentEndpointIdentifier + ) + } + } + + 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 { + let pendingProofs = try await loadProofs() + guard !pendingProofs.isEmpty else { return } + guard let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + let publicKey = identityStatus.publicKey, + let identity = PubkyPublicKeyFormat.normalized(publicKey) + else { return } + + let identityProofs = pendingProofs.filter { + PubkyPublicKeyFormat.matches($0.identity, identity) + } + for proof in identityProofs { + 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 submitInBackground(_ proof: PendingPaykitPaymentProof) { + Task { [weak self] in + await self?.submit(proof) + } + } + + private func submitOnchainPaymentInBackground( + _ request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String + ) { + Task { [weak self] in + guard let self else { return } + do { + var proof = try await pendingProof( + request: request, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: .onchain + ) + proof.paymentIdentifier = txid.lowercased() + proof.proofData = txid.lowercased() + await submit(proof) + } catch { + logWarning("Failed to complete a Paykit on-chain payment proof: \(error)") + } + } + } + + private func removeRequestProofs(_ proof: PendingPaykitPaymentProof) async { + await removeProofs { + PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId + } + } + + 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/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 60e038638..82561e071 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -205,21 +205,20 @@ struct PaymentNavigationHelper { return nil } + if app.contactPaymentContext?.incomingPaymentRequest != nil { + switch route { + case .quickpay, .amount: + return app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + case .lnurlPayAmount: + return .lnurlPayConfirm + default: + return route + } + } + switch route { case .quickpay: return confirmRouteAfterQuickPayCap(app: app) - case .confirm: - if let invoice = app.scannedLightningInvoice { - return invoice.amountSatoshis == 0 ? .amount : .confirm - } - return route - case .amount: - if app.contactPaymentContext?.incomingPaymentRequest != nil, - let invoice = app.scannedOnchainInvoice - { - return invoice.amountSatoshis == 0 ? .amount : .confirm - } - return route default: return route } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 6e53d2a8f..4fb599759 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1074,7 +1074,13 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): + case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat): + Task { + await PaykitPaymentProofService.shared.completeLightningPayment( + paymentHash: paymentHash, + preimage: paymentPreimage + ) + } let outcome = QuickPayPaymentCoordinator.shared.complete( paymentId: paymentId, paymentHash: paymentHash, @@ -1106,6 +1112,9 @@ extension AppViewModel { success: false ) let hash = paymentHash ?? outcome.invoicePaymentHash ?? paymentId + if let paymentHash = paymentHash ?? paymentId { + Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } + } let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false if let hash, awaitingSheet { pendingPaymentHashes.remove(hash) diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index 878147d48..215e3ecc1 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -427,7 +427,8 @@ final class HwSendCoordinator { address: String, sats: UInt64, satsPerVByte: UInt64, - beforeBroadcast: @escaping () async throws -> Void = {} + beforeBroadcast: @escaping () async throws -> Void = {}, + afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in } ) async throws -> HwFundingBroadcastResult { guard let walletId else { throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") @@ -464,7 +465,9 @@ final class HwSendCoordinator { isBroadcastUnresolved = true do { - return try await signer.broadcastSignedFunding(signed) + let result = try await signer.broadcastSignedFunding(signed) + await afterBroadcast(result) + return result } catch { let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index e37cb7592..3e42ac6da 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -63,31 +63,31 @@ struct PaymentRequestCard: View { } .padding(16) } else if onPay != nil || onReject != nil { - HStack(spacing: 12) { + HStack(spacing: 16) { CustomButton( title: t("wallet__payment_request_dismiss"), variant: .secondary, size: .small, icon: Image("x-mark").resizable().frame(width: 16, height: 16), isDisabled: isActionDisabled, - isLoading: isRejecting + isLoading: isRejecting, + shouldExpand: true ) { guard !isRejecting else { return } isRejecting = true await onReject?() isRejecting = false } - .frame(maxWidth: .infinity) CustomButton( title: t("common__pay"), size: .small, icon: Image("coins").resizable().frame(width: 16, height: 16), - isDisabled: isActionDisabled || isRejecting + isDisabled: isActionDisabled || isRejecting, + shouldExpand: true ) { onPay?() } - .frame(maxWidth: .infinity) } .padding(16) .background(Color.gray5) diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 4f5976579..1b72c89ff 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -10,6 +10,8 @@ struct HwSendSignView: View { @Binding var navigationPath: [SendRoute] let hwSend: HwSendCoordinator let prepareContactPayment: () async throws -> Void + let completeContactPayment: (String) async -> Void + let cancelContactPayment: () async -> Void @State private var signingTask: Task? @State private var passphraseTask: Task? @@ -109,7 +111,10 @@ struct HwSendSignView: View { address: invoice.address, sats: amount, satsPerVByte: UInt64(feeRate), - beforeBroadcast: prepareContactPayment + beforeBroadcast: prepareContactPayment, + afterBroadcast: { result in + await completeContactPayment(result.txId) + } ) await recordSentPayment( result, @@ -121,17 +126,26 @@ struct HwSendSignView: View { hwSend.completeBroadcast() navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } catch is CancellationError { + await cancelContactPaymentIfBroadcastIsResolved() return } catch is HwPassphraseError { + await cancelContactPaymentIfBroadcastIsResolved() hwSend.requestPassphrase() } catch let error as HwTransferError { + await cancelContactPaymentIfBroadcastIsResolved() app.toast(error) } catch { + await cancelContactPaymentIfBroadcastIsResolved() showHardwareError(error) } } } + private func cancelContactPaymentIfBroadcastIsResolved() async { + guard !hwSend.isBroadcastUnresolved else { return } + await cancelContactPayment() + } + private func reconnectWithPassphrase(_ passphrase: String) { guard passphraseTask == nil else { return } passphraseTask = Task { @MainActor in diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index e6c938cfa..fe576042a 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,26 @@ struct LnurlPayConfirm: View { navigationPath.append(.pending(paymentHash: timedOutHash, 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 is CancellationError { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } 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 934b94c4c..b0b47a42e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -581,9 +581,25 @@ 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 await prepareContactPaymentIfNeeded() + 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) if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis @@ -592,6 +608,12 @@ struct SendConfirmationView: View { // Create pre-activity metadata for tags and activity address let paymentHash = invoice.paymentHash.hex + if let incomingPaymentRequest { + try await PaykitPaymentProofService.shared.associateLightningPayment( + incomingPaymentRequest, + paymentHash: paymentHash + ) + } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) @@ -608,19 +630,32 @@ struct SendConfirmationView: View { navigationPath.append(.pending(paymentHash: timedOutHash, 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 is CancellationError { + throw CancellationError() } 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) @@ -646,7 +681,15 @@ struct SendConfirmationView: View { domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"] ) } + } catch is CancellationError { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } + return } catch { + if shouldCancelPaymentProof, let incomingPaymentRequest { + await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) + } Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { @@ -662,12 +705,18 @@ struct SendConfirmationView: View { } } - private func prepareContactPaymentIfNeeded() async throws { - let context = app.contactPaymentContext - try validateIncomingPaymentRequestContext(context) - try validateIncomingPaymentRequestAmounts(context) - try await prepareIncomingPaymentRequest() - try validateIncomingPaymentRequestContext(context) + private func paymentProofPreparation() 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 { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index e4254fe07..6fb64c8ae 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -608,7 +608,9 @@ struct SendSheet: View { HwSendSignView( navigationPath: $navigationPath, hwSend: hwSend, - prepareContactPayment: prepareIncomingPaymentRequest + prepareContactPayment: prepareHardwareContactPayment, + completeContactPayment: completeHardwareContactPayment, + cancelContactPayment: cancelHardwareContactPayment ) case .feeRate: SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) @@ -682,6 +684,45 @@ struct SendSheet: View { } } + private func prepareHardwareContactPayment() async throws { + guard let request = app.contactPaymentContext?.incomingPaymentRequest, + let address = app.scannedOnchainInvoice?.address + else { + try await prepareIncomingPaymentRequest() + return + } + + let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentEndpointIdentifier: endpointIdentifier, + kind: .onchain + ) + do { + try await prepareIncomingPaymentRequest() + } catch { + await PaykitPaymentProofService.shared.cancelPreparation(request) + throw error + } + } + + private func completeHardwareContactPayment(txid: String) async { + guard let request = app.contactPaymentContext?.incomingPaymentRequest, + let address = app.scannedOnchainInvoice?.address + else { return } + + await PaykitPaymentProofService.shared.completeOnchainPayment( + request, + txid: txid, + paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue + ) + } + + private func cancelHardwareContactPayment() async { + guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return } + await PaykitPaymentProofService.shared.cancelPreparation(request) + } + private func replaceQuickPay(with route: SendRoute) { app.resetQuickPay() let next = PaymentNavigationHelper.replacingQuickPay(in: navigationPath, root: currentRoot, with: route) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index cff215de9..d108bda4f 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -90,6 +90,7 @@ final class HwFundingSignerTests: XCTestCase { } ) var beforeBroadcastCalls = 0 + var completedTransactionIds: [String] = [] funding.broadcastError = error await assertThrowsAsync { @@ -98,7 +99,8 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcast: { beforeBroadcastCalls += 1 } + beforeBroadcast: { beforeBroadcastCalls += 1 }, + afterBroadcast: { completedTransactionIds.append($0.txId) } ) } @@ -111,7 +113,8 @@ final class HwFundingSignerTests: XCTestCase { address: "bc1qtest", sats: 42000, satsPerVByte: 2, - beforeBroadcast: { beforeBroadcastCalls += 1 } + beforeBroadcast: { beforeBroadcastCalls += 1 }, + afterBroadcast: { completedTransactionIds.append($0.txId) } ) XCTAssertEqual(funding.composeCalls.count, 1) @@ -119,6 +122,7 @@ final class HwFundingSignerTests: XCTestCase { XCTAssertEqual(funding.broadcastCalls, 2) XCTAssertEqual(funding.broadcastTransactions, [funding.signedTx.serializedTx, funding.signedTx.serializedTx]) XCTAssertEqual(beforeBroadcastCalls, 1) + XCTAssertEqual(completedTransactionIds, [funding.broadcastTxId]) } // MARK: - Availability diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift new file mode 100644 index 000000000..336e83490 --- /dev/null +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -0,0 +1,576 @@ +@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 testCanceledLightningWaitPreservesCorrelationForLaterSettlement() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await service.cancelPreparation(request) + + let correlatedProof = await store.snapshot().first + XCTAssertEqual(correlatedProof?.paymentIdentifier, paymentHash) + + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let submissionCount = await sdk.submissionCount() + let remainingProofs = await store.snapshot() + XCTAssertEqual(submissionCount, 1) + XCTAssertTrue(remainingProofs.isEmpty) + } + + func testOnchainPaymentSubmitsTransactionIdForSelectedEndpoint() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + 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 testOnchainCompletionDoesNotWaitForProofDelivery() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + let completion = expectation(description: "On-chain proof state persisted") + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await sdk.suspendSubmission() + let completionTask = Task { + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + completion.fulfill() + } + + await fulfillment(of: [completion], timeout: 1) + await sdk.waitForSubmissionStart() + + let persistedProof = await store.snapshot().first + XCTAssertEqual(persistedProof?.paymentIdentifier, txid) + XCTAssertEqual(persistedProof?.proofData, txid) + + await sdk.resumeSubmission() + await completionTask.value + } + + func testLightningRetryPreservesEarlierPaymentCorrelation() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + 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 proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + + 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 + ) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + 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 proofRemoved = expectation(description: "On-chain proof removed after submission") + let store = PaymentProofMemoryStore { proofs in + if proofs.isEmpty { + proofRemoved.fulfill() + } + } + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + + try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + await store.failNextLoad() + await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await sdk.waitForSubmissionStart() + await fulfillment(of: [proofRemoved], timeout: 1) + + let submittedProof = await sdk.lastSubmission() + let submission = try XCTUnwrap(submittedProof) + 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 testReconcileWithoutPendingProofsSkipsSdk() async { + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: []) + let service = paymentProofService(sdk: sdk, store: store) + + await service.reconcile() + + let identityStatusCallCount = await sdk.identityStatusCallCount() + XCTAssertEqual(identityStatusCallCount, 0) + } + + private func paymentProofService( + sdk: PaymentProofSdkMock, + store: PaymentProofMemoryStore, + 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 + private let onSave: @Sendable ([PendingPaykitPaymentProof]) -> Void + + init(onSave: @escaping @Sendable ([PendingPaykitPaymentProof]) -> Void = { _ in }) { + self.onSave = onSave + } + + 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 + onSave(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 + private var identityStatusCalls = 0 + private var shouldSuspendSubmission = false + private var submissionContinuation: CheckedContinuation? + private var submissionStartContinuations: [CheckedContinuation] = [] + + init(identity: String, records: [PaymentRequestRecord]) { + self.identity = identity + self.records = records + } + + func identityStatus() -> IdentityStatus? { + identityStatusCalls += 1 + return 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 + ) async throws -> PaymentRequestRecord { + submissions.append(proof) + submissionStartContinuations.forEach { $0.resume() } + submissionStartContinuations.removeAll() + if shouldSuspendSubmission { + await withCheckedContinuation { continuation in + submissionContinuation = continuation + } + } + if shouldFailSubmission { + throw PaymentProofSdkMockError.submission + } + + 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 suspendSubmission() { + shouldSuspendSubmission = true + } + + func resumeSubmission() { + shouldSuspendSubmission = false + submissionContinuation?.resume() + submissionContinuation = nil + } + + func waitForSubmissionStart() async { + guard submissions.isEmpty else { return } + await withCheckedContinuation { continuation in + submissionStartContinuations.append(continuation) + } + } + + func submissionCount() -> Int { + submissions.count + } + + func lastSubmission() -> PaymentProofSubmission? { + submissions.last + } + + func processCallCount() -> Int { + privateMessageProcessCallCount + } + + func identityStatusCallCount() -> Int { + identityStatusCalls + } +} + +private enum PaymentProofSdkMockError: Error { + case requestMissing + case submission +} + +private enum PaymentProofStoreMockError: Error { + case load + case save +} diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index adf098be2..b304b7f05 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import Paykit import XCTest @MainActor @@ -149,6 +150,37 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + func testIncomingRequestWithAmountlessLightningInvoiceOpensConfirm() throws { + let app = appWithInvoice(paymentHash: "incoming", amountSatoshis: 0) + app.contactPaymentContext = try ContactPaymentContext( + publicKey: "pubkycontact", + incomingPaymentRequest: incomingPaymentRequest( + endpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + ) + ) + + XCTAssertEqual(contactPaymentRoute(for: app), .confirm) + } + + func testIncomingRequestWithAmountlessOnchainInvoiceOpensConfirm() throws { + let app = AppViewModel() + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qexample", + amountSatoshis: 0, + label: nil, + message: nil, + params: nil + ) + app.contactPaymentContext = try ContactPaymentContext( + publicKey: "pubkycontact", + incomingPaymentRequest: incomingPaymentRequest( + endpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + ) + ) + + XCTAssertEqual(contactPaymentRoute(for: app), .confirm) + } + func testReserveRaceFallsBackToConfirm() { XCTAssertEqual( PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: appWithEligibleInvoice), @@ -184,16 +216,25 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + private func contactPaymentRoute(for app: AppViewModel) -> SendRoute? { + PaymentNavigationHelper.contactPaymentRoute( + app: app, + currency: CurrencyViewModel(), + settings: settings, + spendStore: spendStore + ) + } + private var appWithEligibleInvoice: AppViewModel { appWithInvoice(paymentHash: "") } - private func appWithInvoice(paymentHash: String) -> AppViewModel { + private func appWithInvoice(paymentHash: String, amountSatoshis: UInt64 = 1000) -> AppViewModel { let app = AppViewModel() app.scannedLightningInvoice = LightningInvoice( bolt11: "test-invoice", paymentHash: paymentHash.hexaData, - amountSatoshis: 1000, + amountSatoshis: amountSatoshis, timestampSeconds: 0, expirySeconds: 0, isExpired: false, @@ -204,6 +245,41 @@ final class PaymentNavigationHelperTests: XCTestCase { return app } + private func incomingPaymentRequest(endpointIdentifier: String) throws -> PaykitPaymentRequest { + let record = try PaymentRequestRecord( + counterparty: "pubkycontact", + counterpartyReceiverPath: "bitkit/wallet", + paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: [endpointIdentifier], + metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + return try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date(timeIntervalSince1970: 0))) + } + private var usdRate: FxRate { FxRate( symbol: "BTCUSD", 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.