diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index c297be4f6..28eea77e5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1094,6 +1094,7 @@ "wallet__activity_sent" = "Sent"; "wallet__activity_received" = "Received"; "wallet__activity_pending" = "Pending"; +"wallet__activity_pending_nav_title" = "Payment Pending"; "wallet__activity_failed" = "Failed"; "wallet__activity_transfer" = "Transfer"; "wallet__activity_transfer_savings_pending" = "From Spending (±{duration})"; diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index b1301a7b4..92bb29a37 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -62,6 +62,10 @@ enum KeychainError: LocalizedError { } } +enum PaymentTimeoutError: Error { + case timedOut +} + enum BlocktankError_deprecated: Error { case missingResponse case invalidResponse diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e6c628a93..4bfb944dc 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -3,6 +3,13 @@ import Combine import LDKNode import SwiftUI +/// Published when a payment shown on the pending screen succeeds or fails. +/// SendPendingScreen observes this and navigates accordingly. +struct SendSheetPendingResolution: Equatable { + let paymentHash: String + let success: Bool +} + enum ManualEntryValidationResult: Equatable { case valid case empty @@ -54,6 +61,14 @@ class AppViewModel: ObservableObject { // Drawer menu @Published var showDrawer = false + /// Payment hashes for which we navigated to the pending screen. + /// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate. + private var pendingPaymentHashes: Set = [] + + /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. + /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. + @Published var sendSheetPendingResolution: SendSheetPendingResolution? + // App status init - shows "ready" until node is actually running // This prevents flashing error status during startup/background transitions @Published var appStatusInit: Bool = false @@ -276,6 +291,24 @@ extension AppViewModel { } } +// MARK: Pending payment tracking + +extension AppViewModel { + func addPendingPaymentHash(_ hash: String) { + pendingPaymentHashes.insert(hash) + } + + func removePendingPaymentHash(_ hash: String) { + pendingPaymentHashes.remove(hash) + } + + /// Called by SendPendingScreen when it consumes a resolution. Clears the published value. + func consumeSendSheetPendingResolution(paymentHash hash: String) { + guard sendSheetPendingResolution?.paymentHash == hash else { return } + sendSheetPendingResolution = nil + } +} + // MARK: Scanning/pasting handling extension AppViewModel { @@ -785,18 +818,32 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, paymentPreimage, feePaidMsat): - // TODO: fee is not the sats sent. Need to get this amount from elsewhere like send flow or something. - break + case let .paymentSuccessful(paymentId, paymentHash, _, _): + let hash = paymentId ?? paymentHash + if pendingPaymentHashes.contains(hash) { + pendingPaymentHashes.remove(hash) + sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: true) + toast( + type: .lightning, + title: t("wallet__toast_payment_success_title"), + description: t("wallet__toast_payment_success_description"), + accessibilityIdentifier: "PaymentSuccessToast" + ) + } + case let .paymentFailed(paymentId, paymentHash, _): + let hash = paymentId ?? paymentHash + if let hash, pendingPaymentHashes.contains(hash) { + pendingPaymentHashes.remove(hash) + sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false) + toast( + type: .error, + title: t("wallet__toast_payment_failed_title"), + description: t("wallet__toast_payment_failed_description"), + accessibilityIdentifier: "PaymentFailedToast" + ) + } case .paymentClaimable: break - case .paymentFailed(paymentId: _, paymentHash: _, reason: _): - toast( - type: .error, - title: t("wallet__toast_payment_failed_title"), - description: t("wallet__toast_payment_failed_description"), - accessibilityIdentifier: "PaymentFailedToast" - ) case .paymentForwarded: break diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 95c9033f7..d1d8c20e5 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -251,7 +251,6 @@ class WalletViewModel: ObservableObject { break } - var info: IBtInfo? do { info = try await coreService.blocktank.info(refresh: true) @@ -519,6 +518,30 @@ class WalletViewModel: ObservableObject { return try await lightningService.estimateRoutingFees(bolt11: bolt11, amountSats: amountSats) } + /// Sends a lightning payment with an optional timeout. + /// If the payment does not complete within `timeoutSeconds`, throws `PaymentTimeoutError.timedOut`. + /// The payment continues in the background; caller should navigate to pending screen on timeout. + /// `onTimeout` is called when the timeout fires (before throwing), so the UI can navigate even if the throw doesn't propagate. + @discardableResult + func sendWithTimeout( + bolt11: String, + sats: UInt64? = nil, + timeoutSeconds: TimeInterval = 10, + onTimeout: (@MainActor () -> Void)? = nil + ) async throws -> PaymentHash { + try await withThrowingTaskGroup(of: PaymentHash.self) { group in + group.addTask { try await self.send(bolt11: bolt11, sats: sats) } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + if let onTimeout { await MainActor.run { onTimeout() } } + throw PaymentTimeoutError.timedOut + } + let first = try await group.next()! + group.cancelAll() + return first + } + } + /// Sends a lightning payment and waits for the result using async/await /// A LN payment can throw an error right away, be successful right away, /// or take a while to complete/fail because it's retrying different paths. diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 827698c77..21341cd26 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -139,9 +139,26 @@ struct ActivityExplorerView: View { } } + private var navTitle: String { + switch item { + case let .lightning(activity) where activity.status == .pending: + return t("wallet__activity_pending_nav_title") + case let .lightning(activity) where activity.txType == .sent: + return t("wallet__activity_bitcoin_sent") + case let .onchain(activity) where activity.isTransfer: + return activity.txType == .sent + ? t("wallet__activity_transfer_spending_done") + : t("wallet__activity_transfer_savings_done") + case let .onchain(activity) where activity.txType == .sent: + return t("wallet__activity_bitcoin_sent") + default: + return t("wallet__activity_bitcoin_received") + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: t("wallet__activity_bitcoin_received")) + NavigationBar(title: navTitle) .padding(.bottom, 16) HStack(alignment: .bottom) { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 4ddd5f4c3..3db604182 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -105,6 +105,10 @@ struct ActivityItemView: View { return t("wallet__activity_boost_fee") } + if case let .lightning(activity) = viewModel.activity, activity.status == .pending { + return t("wallet__activity_pending_nav_title") + } + return isSent ? t("wallet__activity_bitcoin_sent") : t("wallet__activity_bitcoin_received") diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 83cc9d2dc..250b0fefa 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -1,4 +1,5 @@ import BitkitCore +import LDKNode import SwiftUI struct LnurlPayConfirm: View { @@ -214,11 +215,24 @@ struct LnurlPayConfirm: View { comment: comment.isEmpty ? nil : comment ) + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = String(describing: parsedInvoice.paymentHash()) + do { - // Perform the Lightning payment - let paymentHash = try await wallet.send(bolt11: bolt11, sats: wallet.sendAmountSats) + // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) + try await wallet.sendWithTimeout( + bolt11: bolt11, + sats: wallet.sendAmountSats, + onTimeout: { + app.addPendingPaymentHash(paymentHash) + navigationPath.append(.pending(paymentHash: paymentHash)) + } + ) Logger.info("LNURL payment successful: \(paymentHash)") - navigationPath.append(.success(paymentHash)) + navigationPath.append(.success(paymentId: paymentHash)) + } catch is PaymentTimeoutError { + // onTimeout callback already navigated to .pending; suppress throw + return } catch { Logger.error("LNURL payment failed: \(error)") diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9406e60fb..decbff5b5 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -223,7 +223,7 @@ struct SendConfirmationView: View { do { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis - // Set the amount for the success screen + // Set the amount for other screens wallet.sendAmountSats = amount // Create pre-activity metadata for tags and activity address @@ -231,11 +231,24 @@ struct SendConfirmationView: View { createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) - // Perform the Lightning payment - try await wallet.send(bolt11: invoice.bolt11, sats: amount) - Logger.info("Lightning payment successful: \(paymentHash)") - - navigationPath.append(.success(paymentHash)) + // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) + do { + try await wallet.sendWithTimeout( + bolt11: invoice.bolt11, + sats: amount, + onTimeout: { + app.addPendingPaymentHash(paymentHash) + navigationPath.append(.pending(paymentHash: paymentHash)) + } + ) + Logger.info("Lightning payment successful: \(paymentHash)") + navigationPath.append(.success(paymentId: paymentHash)) + } catch is PaymentTimeoutError { + // onTimeout callback already navigated to .pending; suppress throw + return + } catch { + throw error + } } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Use sendAll if explicitly MAX or if change would be dust @@ -250,7 +263,7 @@ struct SendConfirmationView: View { Logger.info("Onchain send result txid: \(txid)") - navigationPath.append(.success(txid)) + navigationPath.append(.success(paymentId: txid)) } else { throw NSError( domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid payment method or missing invoice data"] diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift new file mode 100644 index 000000000..b0d5e5b8c --- /dev/null +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -0,0 +1,106 @@ +import BitkitCore +import SwiftUI + +struct HourglassLoadingView: View { + @State private var rotation: Double = -16 + + private var size: CGFloat { UIScreen.main.isSmall ? 160 : 256 } + + var body: some View { + Image("hourglass") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .rotationEffect(.degrees(rotation)) + .frame(maxWidth: .infinity) + .onAppear { + withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { + rotation = 16 + } + } + } +} + +struct SendPendingScreen: View { + let paymentHash: String + @Binding var navigationPath: [SendRoute] + + @EnvironmentObject private var activityList: ActivityListViewModel + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var foundActivity: Activity? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("wallet__send_pending"), showBackButton: false) + + if let sendAmountSats = wallet.sendAmountSats { + MoneyStack(sats: Int(sendAmountSats), showSymbol: true) + .padding(.bottom, 32) + } + + BodyMText(t("wallet__send_pending_note")) + + Spacer() + + HourglassLoadingView() + + Spacer() + + HStack(spacing: 16) { + CustomButton( + title: t("wallet__send_details"), + variant: .secondary, + isDisabled: foundActivity == nil + ) { + if let foundActivity { + navigation.navigate(.activityDetail(foundActivity)) + sheets.hideSheet() + } + } + + CustomButton(title: t("common__close")) { + sheets.hideSheet() + } + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .sheetBackground() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await searchForActivity() + } + .onChange(of: app.sendSheetPendingResolution) { resolution in + guard let resolution, resolution.paymentHash == paymentHash else { return } + app.consumeSendSheetPendingResolution(paymentHash: paymentHash) + if resolution.success { + navigationPath.append(.success(paymentId: paymentHash)) + } else { + navigationPath.append(.failure) + } + } + .onDisappear { + // Remove the pending payment hash from the app model when the screen disappears + app.removePendingPaymentHash(paymentHash) + } + } + + private func searchForActivity() async { + do { + try? await activityList.syncLdkNodePayments() + + let activity = try await tryNTimes( + toTry: { try await activityList.findActivity(byPaymentId: paymentHash) }, + times: 12, + interval: 2 + ) + foundActivity = activity + } catch { + Logger.warn("Could not find activity for pending payment \(paymentHash): \(error)") + } + } +} diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 49d26464b..b2d837d06 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -1,3 +1,4 @@ +import LDKNode import SwiftUI struct LoadingView: View { @@ -106,10 +107,24 @@ struct SendQuickpay: View { ) } + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) + let paymentHash = String(describing: parsedInvoice.paymentHash()) + let amount = wallet.sendAmountSats + do { - let paymentHash = try await wallet.send(bolt11: bolt11, sats: wallet.sendAmountSats) + try await wallet.sendWithTimeout( + bolt11: bolt11, + sats: amount, + onTimeout: { + app.addPendingPaymentHash(paymentHash) + navigationPath.append(.pending(paymentHash: paymentHash)) + } + ) Logger.info("Quickpay payment successful: \(paymentHash)") - navigationPath.append(.success(paymentHash)) + navigationPath.append(.success(paymentId: paymentHash)) + } catch is PaymentTimeoutError { + // onTimeout callback already navigated to .pending; suppress throw + return } catch { Logger.error("Quickpay payment failed: \(error)") diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 28046fdc5..189e622fe 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -10,7 +10,8 @@ enum SendRoute: Hashable { case feeCustom case tag case quickpay - case success(String) + case pending(paymentHash: String) + case success(paymentId: String) case failure case lnurlPayAmount case lnurlPayConfirm @@ -277,6 +278,8 @@ struct SendSheet: View { SendTagScreen(navigationPath: $navigationPath) case .quickpay: SendQuickpay(navigationPath: $navigationPath) + case let .pending(paymentHash): + SendPendingScreen(paymentHash: paymentHash, navigationPath: $navigationPath) case let .success(paymentId): SendSuccess(paymentId: paymentId) case .failure: