diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 35ab2455b..3ab3214ed 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -161,6 +161,7 @@ Constants/Env.swift, Extensions/HexBytes.swift, "Extensions/LDKNode+AddressType.swift", + "Extensions/PaymentFailureReason+UserMessage.swift", Extensions/PaymentDetails.swift, Models/BlocksWidgetOptions.swift, Models/BlocktankNotificationType.swift, @@ -195,6 +196,7 @@ Constants/Env.swift, Extensions/HexBytes.swift, "Extensions/LDKNode+AddressType.swift", + "Extensions/PaymentFailureReason+UserMessage.swift", Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift index 2190ec9bc..0588de453 100644 --- a/Bitkit/Components/SwipeButton.swift +++ b/Bitkit/Components/SwipeButton.swift @@ -12,7 +12,9 @@ struct SwipeButton: View { @State private var offset: CGFloat = 0 @State private var isSubmitting = false - private var isBusy: Bool { isLoading || isSubmitting } + private var isBusy: Bool { + isLoading || isSubmitting + } private let buttonHeight: CGFloat = 76 private let innerPadding: CGFloat = 16 @@ -94,17 +96,9 @@ struct SwipeButton: View { Task { @MainActor in do { try await onComplete() + reset() } catch { - // Reset the slider back to the start on error - withAnimation(.spring(duration: 0.3)) { - offset = 0 - swipeProgress?.wrappedValue = 0 - } - - // Adjust the delay to match animation duration - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isSubmitting = false - } + reset() } } } else { @@ -120,6 +114,17 @@ struct SwipeButton: View { .frame(height: buttonHeight) } + private func reset() { + withAnimation(.spring(duration: 0.3)) { + offset = 0 + swipeProgress?.wrappedValue = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isSubmitting = false + } + } + private var backgroundGradient: LinearGradient { let colors: [Color] = [Color(hex: 0x2A2A2A), Color(hex: 0x1C1C1C)] return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom) diff --git a/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift index 6ebf71829..b57694ad3 100644 --- a/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift +++ b/Bitkit/Extensions/PaymentFailureReason+UserMessage.swift @@ -5,17 +5,38 @@ extension PaymentFailureReason { /// `PaymentFailureReason.toUserMessage`; reasons without a dedicated string fall back to /// the generic payment-failed description. static func userMessage(for reason: PaymentFailureReason?) -> String { + t(userMessageKey(for: reason)) + } + + static func userMessageKey(for reason: PaymentFailureReason?) -> String { switch reason { case .recipientRejected: - return t("wallet__toast_payment_failed_recipient_rejected") + return "wallet__payment_recipient_rejected" + case .userAbandoned: + return "wallet__payment_abandoned" case .retriesExhausted: - return t("wallet__toast_payment_failed_retries_exhausted") - case .routeNotFound: - return t("wallet__toast_payment_failed_route_not_found") + return "wallet__payment_retries_exhausted" case .paymentExpired: - return t("wallet__toast_payment_failed_timeout") + return "wallet__payment_expired" + case .routeNotFound: + return "wallet__payment_route_not_found" + case .unknownRequiredFeatures: + return "wallet__payment_unknown_required_features" + case .invoiceRequestExpired: + return "wallet__payment_invoice_request_expired" + case .invoiceRequestRejected: + return "wallet__payment_invoice_request_rejected" + default: + return "wallet__payment_failed_description" + } + } + + var shouldResetRoutingCachesOnRetry: Bool { + switch self { + case .routeNotFound, .retriesExhausted: + return true default: - return t("wallet__toast_payment_failed_description") + return false } } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 215d58ace..439e3d7b6 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -561,7 +561,7 @@ struct MainNavView: View { case .reset: ResetScreen() // Support settings - case .reportIssue: ReportIssue() + case let .reportIssue(prefill): ReportIssue(prefill: prefill) case .appStatus: AppStatusView() // Advanced settings diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 58349945b..03065b3f0 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1,4 +1,13 @@ "common__delete_yes" = "نعم، احذف"; +"wallet__payment_recipient_rejected" = "رفض المستلم دفعة Lightning هذه. تحقق من الفاتورة وحاول مرة أخرى."; +"wallet__payment_abandoned" = "تم إيقاف دفعة Lightning قبل اكتمالها."; +"wallet__payment_expired" = "انتهت صلاحية دفعة Lightning هذه. اطلب فاتورة جديدة."; +"wallet__payment_timeout" = "انتهت مهلة الدفع. حاول مرة أخرى."; +"wallet__payment_route_not_found" = "لم يتمكن Bitkit من العثور على مسار Lightning لهذه الدفعة."; +"wallet__payment_retries_exhausted" = "جرّب Bitkit عدة مسارات Lightning، لكن تعذر إكمال الدفعة."; +"wallet__payment_unknown_required_features" = "تستخدم فاتورة Lightning هذه ميزات لا يدعمها Bitkit بعد."; +"wallet__payment_invoice_request_expired" = "انتهت صلاحية طلب فاتورة Lightning هذا. اطلب فاتورة جديدة."; +"wallet__payment_invoice_request_rejected" = "رفض المستلم طلب فاتورة Lightning هذا."; "settings__backup__category_contacts" = "جهات الاتصال"; "slashtags__your_name" = "اسمك"; "slashtags__your_name_capital" = "اسمك"; @@ -59,3 +68,4 @@ "widgets__weather__current_fee" = "متوسط الرسوم الحالي"; "widgets__weather__next_block" = "تضمين الكتلة التالية"; "wallet__payment_request" = "طلب دفع"; +"wallet__send_error_support" = "الدعم"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 80ea17aa7..2a33bf72d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -850,7 +850,16 @@ "wallet__tags_no" = "Cap etiqueta disponible encara"; "wallet__toast_payment_success_title" = "Pagament enviat"; "wallet__toast_payment_failed_title" = "Pagament fallit"; -"wallet__toast_payment_failed_description" = "El teu pagament instantani ha fallat. Si us plau, torna-ho a provar."; +"wallet__payment_failed_description" = "El teu pagament instantani ha fallat. Si us plau, torna-ho a provar."; +"wallet__payment_recipient_rejected" = "El destinatari ha rebutjat aquest pagament Lightning. Comprova la factura i torna-ho a provar."; +"wallet__payment_abandoned" = "El pagament Lightning s'ha aturat abans de completar-se."; +"wallet__payment_expired" = "Aquest pagament Lightning ha caducat. Demana una factura nova."; +"wallet__payment_timeout" = "El pagament ha esgotat el temps d'espera. Torna-ho a provar."; +"wallet__payment_route_not_found" = "Bitkit no ha pogut trobar cap ruta Lightning per a aquest pagament."; +"wallet__payment_retries_exhausted" = "Bitkit ha provat diverses rutes Lightning, però el pagament no s'ha pogut completar."; +"wallet__payment_unknown_required_features" = "Aquesta factura Lightning utilitza funcions que Bitkit encara no admet."; +"wallet__payment_invoice_request_expired" = "Aquesta sol·licitud de factura Lightning ha caducat. Demana una factura nova."; +"wallet__payment_invoice_request_rejected" = "El destinatari ha rebutjat aquesta sol·licitud de factura Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transacció rebuda substituïda"; "wallet__toast_received_transaction_replaced_description" = "La teva transacció rebuda s\'ha substituït per un augment de comissió"; "wallet__toast_transaction_replaced_title" = "Transacció substituïda"; @@ -1015,3 +1024,4 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Mitjanes"; "widgets__weather__condition__poor__short_title" = "Dolentes"; +"wallet__send_error_support" = "Contacta amb el suport"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index e39522383..d24013cb8 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -936,6 +936,7 @@ "wallet__send_error_slash_ln" = "Bohužel kontakt nemohl obdržet okamžitou platbu. Můžete zkusit běžnou platbu (dražší, pomalejší)."; "wallet__send_error_tx_failed" = "Transakce selhala"; "wallet__send_error_create_tx" = "Transakci se nepodařilo provést. Zkuste to prosím znovu."; +"wallet__send_error_support" = "Kontaktovat podporu"; "wallet__tag_remove_error_title" = "Odstranění značky se nezdařilo"; "wallet__tag_remove_error_description" = "Bitkit se nepodařilo najít data transakce."; "wallet__error_no_invoice" = "Nebyla nalezena žádná lightningová faktura."; @@ -979,7 +980,16 @@ "wallet__toast_payment_success_title" = "Platba odeslána"; "wallet__toast_payment_success_description" = "Vaše okamžitá platba byla úspěšně odeslána."; "wallet__toast_payment_failed_title" = "Platba se nezdařila"; -"wallet__toast_payment_failed_description" = "Vaše okamžitá platba se nezdařila. Zkuste to prosím znovu."; +"wallet__payment_failed_description" = "Vaše okamžitá platba se nezdařila. Zkuste to prosím znovu."; +"wallet__payment_recipient_rejected" = "Příjemce tuto Lightning platbu odmítl. Zkontrolujte fakturu a zkuste to znovu."; +"wallet__payment_abandoned" = "Lightning platba byla zastavena před dokončením."; +"wallet__payment_expired" = "Platnost této Lightning platby vypršela. Vyžádejte si novou fakturu."; +"wallet__payment_timeout" = "Časový limit platby vypršel. Zkuste to znovu."; +"wallet__payment_route_not_found" = "Bitkit nenašel pro tuto platbu žádnou Lightning trasu."; +"wallet__payment_retries_exhausted" = "Bitkit vyzkoušel několik Lightning tras, ale platbu se nepodařilo dokončit."; +"wallet__payment_unknown_required_features" = "Tato Lightning faktura používá funkce, které Bitkit zatím nepodporuje."; +"wallet__payment_invoice_request_expired" = "Platnost této žádosti o Lightning fakturu vypršela. Vyžádejte si novou fakturu."; +"wallet__payment_invoice_request_rejected" = "Příjemce tuto žádost o Lightning fakturu odmítl."; "wallet__toast_received_transaction_replaced_title" = "Přijatá transakce nahrazena"; "wallet__toast_received_transaction_replaced_description" = "Vaše přijatá transakce byla nahrazena navýšením poplatku"; "wallet__toast_transaction_replaced_title" = "Transakce nahrazena"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 12432122b..58d0dbb5b 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -933,6 +933,7 @@ "wallet__send_error_slash_ln" = "Dieser Kontakt konnte leider nicht sofort bezahlt werden. Du kannst eine reguläre Zahlung versuchen (teurer, langsamer)."; "wallet__send_error_tx_failed" = "Transaktion fehlgeschlagen"; "wallet__send_error_create_tx" = "Die Transaktion konnte nicht gesendet werden. Bitte versuche es erneut."; +"wallet__send_error_support" = "Support kontaktieren"; "wallet__tag_remove_error_title" = "Tag entfernen fehlgeschlagen"; "wallet__tag_remove_error_description" = "Bitkit konnte die Transaktionsdaten nicht finden."; "wallet__error_no_invoice" = "Keine Lightning-Rechnung gefunden."; @@ -976,7 +977,16 @@ "wallet__toast_payment_success_title" = "Zahlung gesendet"; "wallet__toast_payment_success_description" = "Deine sofortige Zahlung wurde erfolgreich gesendet."; "wallet__toast_payment_failed_title" = "Zahlung fehlgeschlagen"; -"wallet__toast_payment_failed_description" = "Deine sofortige Zahlung ist fehlgeschlagen. Bitte versuche es erneut."; +"wallet__payment_failed_description" = "Deine sofortige Zahlung ist fehlgeschlagen. Bitte versuche es erneut."; +"wallet__payment_recipient_rejected" = "Der Empfänger hat diese Lightning-Zahlung abgelehnt. Bitte prüfe die Rechnung und versuche es erneut."; +"wallet__payment_abandoned" = "Die Lightning-Zahlung wurde gestoppt, bevor sie abgeschlossen wurde."; +"wallet__payment_expired" = "Diese Lightning-Zahlung ist abgelaufen. Bitte fordere eine neue Rechnung an."; +"wallet__payment_timeout" = "Zeitüberschreitung bei der Zahlung. Bitte versuche es erneut."; +"wallet__payment_route_not_found" = "Bitkit konnte keine Lightning-Route für diese Zahlung finden."; +"wallet__payment_retries_exhausted" = "Bitkit hat mehrere Lightning-Routen ausprobiert, aber die Zahlung konnte nicht abgeschlossen werden."; +"wallet__payment_unknown_required_features" = "Diese Lightning-Rechnung verwendet Funktionen, die Bitkit noch nicht unterstützt."; +"wallet__payment_invoice_request_expired" = "Diese Lightning-Rechnungsanfrage ist abgelaufen. Bitte fordere eine neue Rechnung an."; +"wallet__payment_invoice_request_rejected" = "Der Empfänger hat diese Lightning-Rechnungsanfrage abgelehnt."; "wallet__toast_received_transaction_replaced_title" = "Empfangene Transaktion ersetzt"; "wallet__toast_received_transaction_replaced_description" = "Deine empfangene Transaktion wurde durch eine Gebührenerhöhung ersetzt"; "wallet__toast_transaction_replaced_title" = "Transaktion ersetzt"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index e59f33aa7..81eb72f6c 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -723,7 +723,16 @@ "wallet__tags_filter_title" = "Επιλογή Ετικέτας"; "wallet__tags_no" = "Δεν υπάρχουν διαθέσιμες ετικέτες ακόμα"; "wallet__toast_payment_failed_title" = "Η Πληρωμή Απέτυχε"; -"wallet__toast_payment_failed_description" = "Η άμεση πληρωμή σας απέτυχε. Παρακαλώ δοκιμάστε ξανά."; +"wallet__payment_failed_description" = "Η άμεση πληρωμή σας απέτυχε. Παρακαλώ δοκιμάστε ξανά."; +"wallet__payment_recipient_rejected" = "Ο παραλήπτης απέρριψε αυτήν την πληρωμή Lightning. Ελέγξτε το τιμολόγιο και δοκιμάστε ξανά."; +"wallet__payment_abandoned" = "Η πληρωμή Lightning σταμάτησε πριν ολοκληρωθεί."; +"wallet__payment_expired" = "Αυτή η πληρωμή Lightning έληξε. Ζητήστε νέο τιμολόγιο."; +"wallet__payment_timeout" = "Το χρονικό όριο πληρωμής έληξε. Δοκιμάστε ξανά."; +"wallet__payment_route_not_found" = "Το Bitkit δεν μπόρεσε να βρει διαδρομή Lightning για αυτήν την πληρωμή."; +"wallet__payment_retries_exhausted" = "Το Bitkit δοκίμασε αρκετές διαδρομές Lightning, αλλά η πληρωμή δεν μπόρεσε να ολοκληρωθεί."; +"wallet__payment_unknown_required_features" = "Αυτό το τιμολόγιο Lightning χρησιμοποιεί λειτουργίες που το Bitkit δεν υποστηρίζει ακόμη."; +"wallet__payment_invoice_request_expired" = "Αυτό το αίτημα τιμολογίου Lightning έληξε. Ζητήστε νέο τιμολόγιο."; +"wallet__payment_invoice_request_rejected" = "Ο παραλήπτης απέρριψε αυτό το αίτημα τιμολογίου Lightning."; "wallet__toast_received_transaction_replaced_title" = "Η Ληφθείσα Συναλλαγή Αντικαταστάθηκε"; "wallet__toast_received_transaction_replaced_description" = "Η ληφθείσα συναλλαγή σας αντικαταστάθηκε από ενίσχυση τέλους"; "wallet__toast_transaction_replaced_title" = "Η Συναλλαγή Αντικαταστάθηκε"; @@ -877,3 +886,4 @@ "widgets__weather__condition__good__short_title" = "Ευνοϊκές"; "widgets__weather__condition__average__short_title" = "Μέσες"; "widgets__weather__condition__poor__short_title" = "Κακές"; +"wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0992f83ab..ad53805fc 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1257,11 +1257,12 @@ "wallet__send_quickpay__nav_title" = "QuickPay"; "wallet__send_quickpay__title" = "Paying\ninvoice..."; "wallet__send_pending_note" = "This payment is taking a bit longer than expected. You can continue using Bitkit."; -"wallet__send_instant_failed" = "Instant Payment Failed"; +"wallet__send_instant_failed" = "Payment Failed"; "wallet__send_regular" = "Regular Payment"; "wallet__send_error_slash_ln" = "Unfortunately contact could not be paid instantly. You can try a regular payment (more expensive, slower)."; "wallet__send_error_tx_failed" = "Transaction Failed"; "wallet__send_error_create_tx" = "Unable to broadcast the transaction. Please try again."; +"wallet__send_error_support" = "Contact Support"; "wallet__tag_remove_error_title" = "Removing Tag Failed"; "wallet__tag_remove_error_description" = "Bitkit was unable to find the transaction data."; "wallet__error_no_invoice" = "No lightning invoice found."; @@ -1305,11 +1306,16 @@ "wallet__toast_payment_success_title" = "Payment Sent"; "wallet__toast_payment_success_description" = "Your instant payment was sent successfully."; "wallet__toast_payment_failed_title" = "Payment Failed"; -"wallet__toast_payment_failed_description" = "Your instant payment failed. Please try again."; -"wallet__toast_payment_failed_timeout" = "Payment timed out. Please try again."; -"wallet__toast_payment_failed_recipient_rejected" = "The recipient rejected this payment. Try a different amount."; -"wallet__toast_payment_failed_retries_exhausted" = "Could not find a route with sufficient liquidity. Try a smaller amount or wait and try again."; -"wallet__toast_payment_failed_route_not_found" = "Could not find a payment path to the recipient."; +"wallet__payment_failed_description" = "Your instant payment failed. Please try again."; +"wallet__payment_recipient_rejected" = "The recipient rejected this Lightning payment. Please check the invoice and try again."; +"wallet__payment_abandoned" = "The Lightning payment was stopped before it completed."; +"wallet__payment_expired" = "This Lightning payment expired. Please request a new invoice."; +"wallet__payment_timeout" = "Payment timed out. Please try again."; +"wallet__payment_route_not_found" = "Bitkit couldn't find a Lightning route for this payment."; +"wallet__payment_retries_exhausted" = "Bitkit tried several Lightning routes, but the payment could not be completed."; +"wallet__payment_unknown_required_features" = "This Lightning invoice uses features Bitkit does not support yet."; +"wallet__payment_invoice_request_expired" = "This Lightning invoice request expired. Please request a new invoice."; +"wallet__payment_invoice_request_rejected" = "The recipient rejected this Lightning invoice request."; "wallet__toast_received_transaction_replaced_title" = "Received Transaction Replaced"; "wallet__toast_received_transaction_replaced_description" = "Your received transaction was replaced by a fee bump"; "wallet__toast_transaction_replaced_title" = "Transaction Replaced"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 99068d03a..1518392f1 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -947,6 +947,7 @@ "wallet__send_error_slash_ln" = "Lamentablemente, el contacto no se puede pagar al instante. Puede intentar un pago regular (más caro, más lento)."; "wallet__send_error_tx_failed" = "Transacción ha fallado"; "wallet__send_error_create_tx" = "No se ha podido emitir la transacción. Por favor, inténtelo de nuevo."; +"wallet__send_error_support" = "Contactar a soporte"; "wallet__tag_remove_error_title" = "Fallo al quitar la etiqueta"; "wallet__tag_remove_error_description" = "Bitkit no ha podido encontrar los datos de la transacción."; "wallet__error_no_invoice" = "No se ha encontrado ninguna factura Lightning"; @@ -990,7 +991,16 @@ "wallet__toast_payment_success_title" = "Pago enviado"; "wallet__toast_payment_success_description" = "Su pago instantáneo se ha enviado."; "wallet__toast_payment_failed_title" = "Pago fallido"; -"wallet__toast_payment_failed_description" = "Su pago instantáneo ha fallado. Por favor, inténtelo de nuevo."; +"wallet__payment_failed_description" = "Su pago instantáneo ha fallado. Por favor, inténtelo de nuevo."; +"wallet__payment_recipient_rejected" = "El destinatario rechazó este pago Lightning. Revise la factura e inténtelo de nuevo."; +"wallet__payment_abandoned" = "El pago Lightning se detuvo antes de completarse."; +"wallet__payment_expired" = "Este pago Lightning venció. Solicite una factura nueva."; +"wallet__payment_timeout" = "El pago agotó el tiempo de espera. Inténtelo de nuevo."; +"wallet__payment_route_not_found" = "Bitkit no pudo encontrar una ruta Lightning para este pago."; +"wallet__payment_retries_exhausted" = "Bitkit probó varias rutas Lightning, pero el pago no se pudo completar."; +"wallet__payment_unknown_required_features" = "Esta factura Lightning usa funciones que Bitkit aún no admite."; +"wallet__payment_invoice_request_expired" = "Esta solicitud de factura Lightning venció. Solicite una factura nueva."; +"wallet__payment_invoice_request_rejected" = "El destinatario rechazó esta solicitud de factura Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transacción recibida Reemplazada"; "wallet__toast_received_transaction_replaced_description" = "Tu transacción entrante fue reemplazada al aumentar la comisión"; "wallet__toast_transaction_replaced_title" = "Transacción Sustituida"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index e7b937b0a..2e012ae75 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -871,7 +871,16 @@ "wallet__tags_no" = "Aún no hay etiquetas disponibles"; "wallet__toast_payment_success_title" = "Pago Enviado"; "wallet__toast_payment_failed_title" = "Pago Fallido"; -"wallet__toast_payment_failed_description" = "Tu pago instantáneo falló. Por favor, inténtalo de nuevo."; +"wallet__payment_failed_description" = "Tu pago instantáneo falló. Por favor, inténtalo de nuevo."; +"wallet__payment_recipient_rejected" = "El destinatario ha rechazado este pago Lightning. Comprueba la factura e inténtalo de nuevo."; +"wallet__payment_abandoned" = "El pago Lightning se detuvo antes de completarse."; +"wallet__payment_expired" = "Este pago Lightning ha caducado. Solicita una factura nueva."; +"wallet__payment_timeout" = "El pago agotó el tiempo de espera. Inténtalo de nuevo."; +"wallet__payment_route_not_found" = "Bitkit no ha podido encontrar una ruta Lightning para este pago."; +"wallet__payment_retries_exhausted" = "Bitkit ha probado varias rutas Lightning, pero el pago no se ha podido completar."; +"wallet__payment_unknown_required_features" = "Esta factura Lightning usa funciones que Bitkit aún no admite."; +"wallet__payment_invoice_request_expired" = "Esta solicitud de factura Lightning ha caducado. Solicita una factura nueva."; +"wallet__payment_invoice_request_rejected" = "El destinatario ha rechazado esta solicitud de factura Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transacción Recibida Reemplazada"; "wallet__toast_received_transaction_replaced_description" = "Tu transacción recibida fue reemplazada por un aumento de comisión"; "wallet__toast_transaction_replaced_title" = "Transacción Reemplazada"; @@ -1044,3 +1053,4 @@ "widgets__weather__condition__good__short_title" = "Favorables"; "widgets__weather__condition__average__short_title" = "Promedio"; "widgets__weather__condition__poor__short_title" = "Desfavorables"; +"wallet__send_error_support" = "Contactar con el servicio de asistencia"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index c4505329f..c0be9761c 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -959,6 +959,7 @@ "wallet__send_error_slash_ln" = "Malheureusement, le contact ne peut pas être payé instantanément. Vous pouvez essayer un paiement On-Chain (plus cher, plus lent)."; "wallet__send_error_tx_failed" = "Échec de la transaction"; "wallet__send_error_create_tx" = "Impossible de diffuser la transaction. Veuillez réessayer."; +"wallet__send_error_support" = "Contacter le support"; "wallet__tag_remove_error_title" = "Échec du retrait de l\'étiquette"; "wallet__tag_remove_error_description" = "Bitkit n\'a pas pu trouver les données de la transaction."; "wallet__error_no_invoice" = "Aucune facture Lightning n\'a été trouvée."; @@ -1002,7 +1003,16 @@ "wallet__toast_payment_success_title" = "Paiement envoyé"; "wallet__toast_payment_success_description" = "Votre paiement instantané a été envoyé avec succès."; "wallet__toast_payment_failed_title" = "Échec du paiement"; -"wallet__toast_payment_failed_description" = "Votre paiement instantané a échoué. Veuillez réessayer."; +"wallet__payment_failed_description" = "Votre paiement instantané a échoué. Veuillez réessayer."; +"wallet__payment_recipient_rejected" = "Le destinataire a rejeté ce paiement Lightning. Vérifiez la facture et réessayez."; +"wallet__payment_abandoned" = "Le paiement Lightning a été arrêté avant d'être terminé."; +"wallet__payment_expired" = "Ce paiement Lightning a expiré. Demandez une nouvelle facture."; +"wallet__payment_timeout" = "Le paiement a expiré. Veuillez réessayer."; +"wallet__payment_route_not_found" = "Bitkit n'a pas trouvé de route Lightning pour ce paiement."; +"wallet__payment_retries_exhausted" = "Bitkit a essayé plusieurs routes Lightning, mais le paiement n'a pas pu être terminé."; +"wallet__payment_unknown_required_features" = "Cette facture Lightning utilise des fonctionnalités que Bitkit ne prend pas encore en charge."; +"wallet__payment_invoice_request_expired" = "Cette demande de facture Lightning a expiré. Demandez une nouvelle facture."; +"wallet__payment_invoice_request_rejected" = "Le destinataire a rejeté cette demande de facture Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transaction reçue"; "wallet__toast_received_transaction_replaced_description" = "Votre transaction reçue a été remplacée par une augmentation des frais."; "wallet__toast_transaction_replaced_title" = "Transaction remplacée"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index c8e5c4dbf..7e6b09c3f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -921,6 +921,7 @@ "wallet__send_error_slash_ln" = "Purtroppo il contatto non può essere pagato istantaneamente. Puoi provare con un pagamento regolare (più costoso, più lento)."; "wallet__send_error_tx_failed" = "Transazione Fallita"; "wallet__send_error_create_tx" = "Impossibile trasmettere la transazione. Per favore riprova."; +"wallet__send_error_support" = "Contatta l'Assistenza"; "wallet__tag_remove_error_title" = "Rimozione del tag non riuscita"; "wallet__tag_remove_error_description" = "Bitkit non è riuscito a trovare i dati della transazione."; "wallet__error_no_invoice" = "Nessuna invoice lightning trovata."; @@ -963,7 +964,16 @@ "wallet__toast_payment_success_title" = "Pagamento inviato"; "wallet__toast_payment_success_description" = "Il tuo pagamento istantaneo è stato inviato con successo."; "wallet__toast_payment_failed_title" = "Pagamento fallito"; -"wallet__toast_payment_failed_description" = "Il tuo pagamento istantaneo non è riuscito. Per favore riprova."; +"wallet__payment_failed_description" = "Il tuo pagamento istantaneo non è riuscito. Per favore riprova."; +"wallet__payment_recipient_rejected" = "Il destinatario ha rifiutato questo pagamento Lightning. Controlla la fattura e riprova."; +"wallet__payment_abandoned" = "Il pagamento Lightning è stato interrotto prima del completamento."; +"wallet__payment_expired" = "Questo pagamento Lightning è scaduto. Richiedi una nuova fattura."; +"wallet__payment_timeout" = "Il pagamento è scaduto. Riprova."; +"wallet__payment_route_not_found" = "Bitkit non ha trovato una rotta Lightning per questo pagamento."; +"wallet__payment_retries_exhausted" = "Bitkit ha provato diverse rotte Lightning, ma il pagamento non è stato completato."; +"wallet__payment_unknown_required_features" = "Questa fattura Lightning usa funzionalità che Bitkit non supporta ancora."; +"wallet__payment_invoice_request_expired" = "Questa richiesta di fattura Lightning è scaduta. Richiedi una nuova fattura."; +"wallet__payment_invoice_request_rejected" = "Il destinatario ha rifiutato questa richiesta di fattura Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transazione Ricevuta Sostituita"; "wallet__toast_received_transaction_replaced_description" = "La tua transazione ricevuta è stata sostituita da un aumento commissione"; "wallet__toast_transaction_replaced_title" = "Transazione Sostituita"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index f3f7f44b7..a52d6fd05 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -954,6 +954,7 @@ "wallet__send_error_slash_ln" = "Helaas kon het contact niet direct worden betaald. U kunt proberen een normale betaling te doen (deze is langzamer en duurder). "; "wallet__send_error_tx_failed" = "Transactie Mislukt"; "wallet__send_error_create_tx" = "De transactie niet verzonden worden. Probeer het opnieuw."; +"wallet__send_error_support" = "Contact Opnemen"; "wallet__tag_remove_error_title" = "Tag Verwijderen Mislukt"; "wallet__tag_remove_error_description" = "Bitkit kon de transactiegegevens niet vinden."; "wallet__error_no_invoice" = "Geen lightning factuur gedetecteerd"; @@ -997,7 +998,16 @@ "wallet__toast_payment_success_title" = "Betaling Verzonden"; "wallet__toast_payment_success_description" = "Uw directe betaling is succesvol verzonden."; "wallet__toast_payment_failed_title" = "Betaling Mislukt"; -"wallet__toast_payment_failed_description" = "Uw directe betaling is mislukt. Probeer het opnieuw."; +"wallet__payment_failed_description" = "Uw directe betaling is mislukt. Probeer het opnieuw."; +"wallet__payment_recipient_rejected" = "De ontvanger heeft deze Lightning-betaling geweigerd. Controleer de factuur en probeer het opnieuw."; +"wallet__payment_abandoned" = "De Lightning-betaling is gestopt voordat deze was voltooid."; +"wallet__payment_expired" = "Deze Lightning-betaling is verlopen. Vraag een nieuwe factuur aan."; +"wallet__payment_timeout" = "Time-out voor betaling. Probeer het opnieuw."; +"wallet__payment_route_not_found" = "Bitkit kon geen Lightning-route voor deze betaling vinden."; +"wallet__payment_retries_exhausted" = "Bitkit heeft meerdere Lightning-routes geprobeerd, maar de betaling kon niet worden voltooid."; +"wallet__payment_unknown_required_features" = "Deze Lightning-factuur gebruikt functies die Bitkit nog niet ondersteunt."; +"wallet__payment_invoice_request_expired" = "Deze Lightning-factuuraanvraag is verlopen. Vraag een nieuwe factuur aan."; +"wallet__payment_invoice_request_rejected" = "De ontvanger heeft deze Lightning-factuuraanvraag geweigerd."; "wallet__toast_received_transaction_replaced_title" = "Ontvangen Transactie Vervangen"; "wallet__toast_received_transaction_replaced_description" = "Uw ontvangen transactie is vervangen door een vergoedingsverhoging"; "wallet__toast_transaction_replaced_title" = "Transactie Vervangen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1b7ad6e50..7f1c1fbd9 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -960,6 +960,7 @@ "wallet__send_error_slash_ln" = "Nie udało się wykonać szybkiej płatności. Możesz spróbować regularnej płatności (droższej, wolniejszej)."; "wallet__send_error_tx_failed" = "Transakcja nie powiodła się"; "wallet__send_error_create_tx" = "Nie udało się wysłać transakcji. Proszę spróbować ponownie."; +"wallet__send_error_support" = "Skontaktuj się z pomocą techniczną"; "wallet__tag_remove_error_title" = "Usunięcie tagu nie powiodło się"; "wallet__tag_remove_error_description" = "Bitkit nie był w stanie znaleźć danych transakcji."; "wallet__error_no_invoice" = "Nie znaleziono faktury Lightning."; @@ -1003,7 +1004,16 @@ "wallet__toast_payment_success_title" = "Płatność wysłana"; "wallet__toast_payment_success_description" = "Twoja płatność natychmiastowa została wysłana pomyślnie."; "wallet__toast_payment_failed_title" = "Płatność nie powiodła się"; -"wallet__toast_payment_failed_description" = "Natychmiastowa płatność nie powiodła się. Proszę spróbować ponownie."; +"wallet__payment_failed_description" = "Natychmiastowa płatność nie powiodła się. Proszę spróbować ponownie."; +"wallet__payment_recipient_rejected" = "Odbiorca odrzucił tę płatność Lightning. Sprawdź fakturę i spróbuj ponownie."; +"wallet__payment_abandoned" = "Płatność Lightning została zatrzymana przed zakończeniem."; +"wallet__payment_expired" = "Ta płatność Lightning wygasła. Poproś o nową fakturę."; +"wallet__payment_timeout" = "Przekroczono limit czasu płatności. Spróbuj ponownie."; +"wallet__payment_route_not_found" = "Bitkit nie znalazł trasy Lightning dla tej płatności."; +"wallet__payment_retries_exhausted" = "Bitkit wypróbował kilka tras Lightning, ale płatności nie udało się ukończyć."; +"wallet__payment_unknown_required_features" = "Ta faktura Lightning używa funkcji, których Bitkit jeszcze nie obsługuje."; +"wallet__payment_invoice_request_expired" = "To żądanie faktury Lightning wygasło. Poproś o nową fakturę."; +"wallet__payment_invoice_request_rejected" = "Odbiorca odrzucił to żądanie faktury Lightning."; "wallet__toast_received_transaction_replaced_title" = "Zastąpiono otrzymaną transakcję"; "wallet__toast_received_transaction_replaced_description" = "Twoja otrzymana transakcja została zastąpiona przez zwiększenie opłaty"; "wallet__toast_transaction_replaced_title" = "Transakcja zastąpiona"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 6cbfe9e7f..47500e956 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -961,6 +961,7 @@ "wallet__send_error_slash_ln" = "Infelizmente, o contato não pôde ser pago instantaneamente. Você pode tentar realizar um pagamento regular (mais caro, mais lento)."; "wallet__send_error_tx_failed" = "Falha na Transação"; "wallet__send_error_create_tx" = "Não foi possível transmitir a transação. Por favor, tente novamente."; +"wallet__send_error_support" = "Contactar Suporte"; "wallet__tag_remove_error_title" = "Falha ao Remover a Tag"; "wallet__tag_remove_error_description" = "A Bitkit não conseguiu encontrar os dados da transação."; "wallet__error_no_invoice" = "Não fora encontrada nenhum invoice lightning."; @@ -1004,7 +1005,16 @@ "wallet__toast_payment_success_title" = "Pagamento Enviado"; "wallet__toast_payment_success_description" = "Seu pagamento instantâneo foi enviado com sucesso."; "wallet__toast_payment_failed_title" = "Pagamento Falhou"; -"wallet__toast_payment_failed_description" = "Seu pagamento instantâneo falhou. Por favor, tente novamente."; +"wallet__payment_failed_description" = "Seu pagamento instantâneo falhou. Por favor, tente novamente."; +"wallet__payment_recipient_rejected" = "O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente."; +"wallet__payment_abandoned" = "O pagamento Lightning foi interrompido antes de ser concluído."; +"wallet__payment_expired" = "Este pagamento Lightning expirou. Solicite uma nova fatura."; +"wallet__payment_timeout" = "O pagamento expirou. Tente novamente."; +"wallet__payment_route_not_found" = "O Bitkit não conseguiu encontrar uma rota Lightning para este pagamento."; +"wallet__payment_retries_exhausted" = "O Bitkit tentou várias rotas Lightning, mas o pagamento não pôde ser concluído."; +"wallet__payment_unknown_required_features" = "Esta fatura Lightning usa recursos que o Bitkit ainda não oferece suporte."; +"wallet__payment_invoice_request_expired" = "Esta solicitação de fatura Lightning expirou. Solicite uma nova fatura."; +"wallet__payment_invoice_request_rejected" = "O destinatário rejeitou esta solicitação de fatura Lightning."; "wallet__toast_received_transaction_replaced_title" = "Transação Recebida Substituída"; "wallet__toast_received_transaction_replaced_description" = "Sua transação recebida foi substituída por um aumento de taxa"; "wallet__toast_transaction_replaced_title" = "Transação Substituída"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index ce44c8d3b..89177e45b 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1,4 +1,14 @@ "security__cp_text" = "Você pode alterar seu código PIN para uma nova\ncombinação de 4 dígitos. Primeiro, digite seu código PIN atual."; +"wallet__payment_recipient_rejected" = "O destinatário rejeitou este pagamento Lightning. Verifique a fatura e tente novamente."; +"wallet__payment_abandoned" = "O pagamento Lightning foi interrompido antes de ser concluído."; +"wallet__payment_expired" = "Este pagamento Lightning expirou. Peça uma nova fatura."; +"wallet__payment_timeout" = "O pagamento expirou. Tente novamente."; +"wallet__send_error_support" = "Suporte"; +"wallet__payment_route_not_found" = "O Bitkit não conseguiu encontrar uma rota Lightning para este pagamento."; +"wallet__payment_retries_exhausted" = "O Bitkit tentou várias rotas Lightning, mas o pagamento não pôde ser concluído."; +"wallet__payment_unknown_required_features" = "Esta fatura Lightning usa funcionalidades que o Bitkit ainda não suporta."; +"wallet__payment_invoice_request_expired" = "Este pedido de fatura Lightning expirou. Peça uma nova fatura."; +"wallet__payment_invoice_request_rejected" = "O destinatário rejeitou este pedido de fatura Lightning."; "settings__addr__no_addrs_with_funds" = "Não foi encontrado nenhum endereço contendo \"{searchTxt}\" com fundos "; "settings__addr__no_addrs_str" = "Nenhum endereço foi encontrado ao pesquisar por \"{searchTxt}\""; "slashtags__contact_your_name" = "Nome público\ndo seu perfil"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index e57ed92e4..80c6456c8 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -950,6 +950,7 @@ "wallet__send_error_slash_ln" = "К сожалению, мгновенный платеж к контакту не выполнен. Можно попробовать обычный платеж (дороже, медленнее)."; "wallet__send_error_tx_failed" = "Транзакция не удалась"; "wallet__send_error_create_tx" = "Не удалось отправить транзакцию. Пожалуйста, попробуйте снова."; +"wallet__send_error_support" = "Связаться с Поддержкой"; "wallet__tag_remove_error_title" = "Ошибка удаления тега"; "wallet__tag_remove_error_description" = "Bitkit не удалось найти данные транзакции."; "wallet__error_no_invoice" = "Лайтнинг инвойс не найден."; @@ -992,7 +993,16 @@ "wallet__toast_payment_success_title" = "Платеж отправлен"; "wallet__toast_payment_success_description" = "Ваш мгновенный платеж был успешно отправлен."; "wallet__toast_payment_failed_title" = "Платеж не выполнен"; -"wallet__toast_payment_failed_description" = "Ваш мгновенный платеж не удался. Пожалуйста, попробуйте снова."; +"wallet__payment_failed_description" = "Ваш мгновенный платеж не удался. Пожалуйста, попробуйте снова."; +"wallet__payment_recipient_rejected" = "Получатель отклонил этот платеж Lightning. Проверьте счет и попробуйте снова."; +"wallet__payment_abandoned" = "Платеж Lightning был остановлен до завершения."; +"wallet__payment_expired" = "Срок действия этого платежа Lightning истек. Запросите новый счет."; +"wallet__payment_timeout" = "Время ожидания платежа истекло. Попробуйте еще раз."; +"wallet__payment_route_not_found" = "Bitkit не смог найти маршрут Lightning для этого платежа."; +"wallet__payment_retries_exhausted" = "Bitkit попробовал несколько маршрутов Lightning, но платеж не удалось завершить."; +"wallet__payment_unknown_required_features" = "Этот счет Lightning использует функции, которые Bitkit пока не поддерживает."; +"wallet__payment_invoice_request_expired" = "Срок действия этого запроса счета Lightning истек. Запросите новый счет."; +"wallet__payment_invoice_request_rejected" = "Получатель отклонил этот запрос счета Lightning."; "wallet__toast_received_transaction_replaced_title" = "Полученная Транзакция Заменена"; "wallet__toast_received_transaction_replaced_description" = "Ваша полученная транзакция была заменена из-за повышения комиссии"; "wallet__toast_transaction_replaced_title" = "Транзакция Заменена"; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index b0edb032a..86d0070cc 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -374,6 +374,15 @@ class LightningService { Logger.info("Deleted network graph cache at: \(graphPath.path)") } + func networkGraphCacheModificationDate() -> Date? { + let graphPath = Env.ldkStorage(walletIndex: currentWalletIndex).appendingPathComponent("network_graph_cache") + guard let attributes = try? FileManager.default.attributesOfItem(atPath: graphPath.path), + let modificationDate = attributes[.modificationDate] as? Date + else { return nil } + + return modificationDate + } + func connectToTrustedPeers(remotePeers: [LnPeer]? = nil) async throws { guard let node else { throw AppError(serviceError: .nodeNotSetup) diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift index 7ef20e48b..dc7b7cf60 100644 --- a/Bitkit/Services/VssBackupClient.swift +++ b/Bitkit/Services/VssBackupClient.swift @@ -257,6 +257,12 @@ class VssBackupClient { } } + func deleteLdkScorerCache() async throws { + _ = try await deleteObjectLdk(key: "scorer", namespace: .default) + _ = try await deleteObjectLdk(key: "external_pathfinding_scores_cache", namespace: .default) + Logger.info("Cleared LDK scorer cache from VSS", context: "VssBackupClient") + } + private func awaitSetup() async throws { try await setupCoordinator.awaitSetup { [self] in try await setup() diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 91b09bdac..0e9cb5476 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -66,29 +66,34 @@ enum PaymentTimeoutError: Error { case timedOut } -enum BlocktankError_deprecated: Error { - case missingResponse - case invalidResponse - case invalidJson - case missingDeviceToken -} - /// Translates LDK and BDK error messages into translated messages that can be displayed to end users struct AppError: LocalizedError { + static let genericMessage = "App Error" + let message: String let debugMessage: String? + let paymentFailureReason: PaymentFailureReason? /// The original error this was wrapped from, when known. Preserved so callers can unwrap and /// inspect the underlying error (e.g. `isTrezorUserCancellation()`) after it has been boxed by /// `ServiceQueue` into a generic `AppError`. let underlyingError: Error? var errorDescription: String? { - return NSLocalizedString(message, comment: "") + return t(message) + } + + var isGeneric: Bool { + return message == Self.genericMessage } /// Pass any LDK or BDK error to get a translated error message /// - Parameter error: any error init(error: Error) { + if let appError = error as? AppError { + self = appError + return + } + if let ldkBuildError = error as? BuildError { self.init(ldkBuildError: ldkBuildError) return @@ -106,17 +111,26 @@ struct AppError: LocalizedError { // EsploraError // PersistenceError - self.init(message: "App Error", debugMessage: error.localizedDescription, underlyingError: error) + self.init(message: Self.genericMessage, debugMessage: error.localizedDescription, underlyingError: error) } - init(message: String, debugMessage: String?, underlyingError: Error? = nil) { + init(message: String, debugMessage: String?, underlyingError: Error? = nil, paymentFailureReason: PaymentFailureReason? = nil) { self.message = message self.debugMessage = debugMessage self.underlyingError = underlyingError + self.paymentFailureReason = paymentFailureReason + } + + init(paymentFailureReason reason: PaymentFailureReason?) { + underlyingError = nil + debugMessage = reason.map { String(describing: $0) } ?? "Unknown payment failure reason" + message = PaymentFailureReason.userMessageKey(for: reason) + paymentFailureReason = reason } init(serviceError: CustomServiceError) { underlyingError = serviceError + paymentFailureReason = nil switch serviceError { case .nodeNotSetup: message = "Node is not setup" @@ -164,6 +178,7 @@ struct AppError: LocalizedError { private init(ldkBuildError: BuildError) { underlyingError = ldkBuildError + paymentFailureReason = nil switch ldkBuildError as BuildError { case let .InvalidSeedBytes(message: ldkMessage): message = "Invalid seed bytes" @@ -221,6 +236,7 @@ struct AppError: LocalizedError { private init(ldkError: NodeError) { underlyingError = ldkError + paymentFailureReason = nil switch ldkError as NodeError { case let .AlreadyRunning(message: ldkMessage): message = "Node is already running" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a8b8c4ecd..51cde7da6 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -318,7 +318,7 @@ extension AppViewModel { ) } case .signingTimeout: - toast(type: .error, title: t("common__error"), description: t("wallet__toast_payment_failed_timeout")) + toast(type: .error, title: t("common__error"), description: t("wallet__payment_timeout")) case .broadcastUncertain: toast( type: .warning, @@ -1018,7 +1018,7 @@ extension AppViewModel { toast( type: .error, title: t("wallet__toast_payment_failed_title"), - description: t("wallet__toast_payment_failed_description"), + description: t("wallet__payment_failed_description"), accessibilityIdentifier: "PaymentFailedToast" ) } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 7512e757a..75d864077 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -2,6 +2,10 @@ import BitkitCore import LDKNode import SwiftUI +struct ReportIssuePrefill: Hashable { + let message: String +} + enum Route: Hashable { case savingsWallet case spendingWallet @@ -61,7 +65,7 @@ enum Route: Hashable { case widgetsIntro // Support - case reportIssue + case reportIssue(ReportIssuePrefill? = nil) case appStatus // Settings diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index de489bb24..f4739765d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -91,6 +91,7 @@ class WalletViewModel: ObservableObject { @Published var balanceInTransferToSpending: Int = 0 @Published var forceCloseClaimableAtHeight: UInt32? @Published var currentBlockHeight: UInt32 = 0 + @Published var isRetryingLightningPayment = false init( lightningService: LightningService = .shared, @@ -137,6 +138,11 @@ class WalletViewModel: ObservableObject { } func start(walletIndex: Int = 0) async throws { + if !lightningService.hasNode, nodeLifecycleState == .running { + Logger.warn("Node lifecycle was running but service node is missing, restarting", context: "WalletViewModel") + nodeLifecycleState = .stopped + } + // Guard against concurrent starts - only allow start from stopped, initializing, or error states switch nodeLifecycleState { case .stopped, .initializing, .errorStarting: @@ -415,7 +421,16 @@ class WalletViewModel: ObservableObject { nodeLifecycleState = .stopping // Stop the swap updates stream with the node; it restarts on the next wallet start. await stopSwapUpdates() - try await lightningService.stop(clearEventCallback: clearEventCallback) + + do { + try await lightningService.stop(clearEventCallback: clearEventCallback) + } catch { + Logger.warn("Failed to stop Lightning node: \(error)", context: "WalletViewModel") + nodeLifecycleState = lightningService.hasNode ? .running : .stopped + syncState() + throw error + } + nodeLifecycleState = .stopped probeOutcomes.removeAll() syncState() @@ -887,14 +902,9 @@ class WalletViewModel: ObservableObject { continuation.resume(returning: paymentHash) } case .paymentFailed(paymentId: _, let paymentHash, let reason): - // TODO: this is not working for routeNotFound if paymentHash == hash { self.removeOnEvent(id: eventId) - continuation.resume(throwing: NSError( - domain: "Lightning", - code: -1, - userInfo: [NSLocalizedDescriptionKey: reason.debugDescription] - )) + continuation.resume(throwing: AppError(paymentFailureReason: reason)) } default: break @@ -969,6 +979,59 @@ class WalletViewModel: ObservableObject { try await clearNetworkGraph() } + func resetPaymentRoutingCaches() async throws { + Logger.warn("Resetting payment routing caches", context: "WalletViewModel") + var resetErrors: [Error] = [] + + do { + try await resetNetworkGraph() + } catch { + resetErrors.append(error) + } + + do { + try await VssBackupClient.shared.deleteLdkScorerCache() + } catch { + resetErrors.append(error) + } + + if let firstError = resetErrors.first { + throw firstError + } + } + + func waitForPaymentRoutingDataRefresh(startedAt: Date, timeoutSeconds: Double = 20.0) async throws { + let startedAtTimestamp = UInt64(startedAt.timeIntervalSince1970) + let requiresRgsRefresh = Env.network != .regtest && !rgsConfigService.getCurrentServerUrl().isEmpty + let requiresScorerRefresh = Env.ldkScorerUrl != nil + + guard requiresRgsRefresh || requiresScorerRefresh else { return } + + let startTime = Date() + + while Date().timeIntervalSince(startTime) <= timeoutSeconds { + await lightningService.refreshCache() + if let status = lightningService.status { + let graphCacheModificationDate = lightningService.networkGraphCacheModificationDate() + let rgsFresh = !requiresRgsRefresh || (graphCacheModificationDate ?? .distantPast) > startedAt + + let scorerFresh = !requiresScorerRefresh || (status.latestPathfindingScoresSyncTimestamp ?? 0) >= startedAtTimestamp + + if rgsFresh, scorerFresh { + Logger.info("Payment routing data refreshed", context: "WalletViewModel") + return + } + } + + try await Task.sleep(nanoseconds: 500_000_000) + } + + throw AppError( + message: "wallet__payment_failed_description", + debugMessage: "Timed out waiting for RGS and scorer data before retrying payment" + ) + } + /// Clears the cached Lightning network graph: the local cache file and the VSS backup copy. /// Shared by the legacy one-time startup cleanup, the manual recovery reset, and the LDK debug screen. func clearNetworkGraph() async throws { diff --git a/Bitkit/Views/Settings/Support/ReportIssue.swift b/Bitkit/Views/Settings/Support/ReportIssue.swift index c0ee7c67d..fac3d1728 100644 --- a/Bitkit/Views/Settings/Support/ReportIssue.swift +++ b/Bitkit/Views/Settings/Support/ReportIssue.swift @@ -8,6 +8,10 @@ struct ReportIssue: View { @State private var showingSuccess: Bool = false @State private var showingError: Bool = false + init(prefill: ReportIssuePrefill? = nil) { + _message = State(initialValue: prefill?.message ?? "") + } + private func validateEmail(_ emailText: String) -> Bool { if emailText.contains("@") { let parts = emailText.split(separator: "@") diff --git a/Bitkit/Views/Settings/SupportScreen.swift b/Bitkit/Views/Settings/SupportScreen.swift index 35eaf2ac8..2c8778608 100644 --- a/Bitkit/Views/Settings/SupportScreen.swift +++ b/Bitkit/Views/Settings/SupportScreen.swift @@ -84,7 +84,7 @@ struct SupportScreen: View { .padding(.bottom, 16) VStack(spacing: 0) { - NavigationLink(value: Route.reportIssue) { + NavigationLink(value: Route.reportIssue()) { SettingsRow(title: t("settings__support__report"), iconName: "warning") } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index a49d41852..e38687de1 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -12,6 +12,7 @@ struct LnurlPayConfirm: View { @Binding var navigationPath: [SendRoute] let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void + let routingCacheResetAttempted: Bool @State private var showWarningAlert = false @State private var alertContinuation: CheckedContinuation? @@ -105,52 +106,7 @@ struct LnurlPayConfirm: View { title: t("wallet__send_swipe"), accentColor: .greenAccent ) { - // Check if we need to show warning for amounts over $100 USD - if settings.warnWhenSendingOver100 { - let sats: UInt64 = if let invoice = app.scannedLightningInvoice { - wallet.sendAmountSats ?? invoice.amountSatoshis - } else { - 0 - } - - // Convert to USD to check if over $100 - if let usdAmount = currency.convert(sats: sats, to: "USD") { - if usdAmount.value > 100.0 { - showWarningAlert = true - // Wait for the alert to be dismissed - let shouldProceed = try await waitForAlertDismissal() - if !shouldProceed { - // User cancelled, throw error to reset SwipeButton - throw CancellationError() - } - // User confirmed, continue with authentication if needed - } - } - } - - // Check if authentication is required for payments - if settings.requirePinForPayments && settings.pinEnabled { - if settings.useBiometrics && BiometricAuth.isAvailable { - let result = await BiometricAuth.authenticate() - switch result { - case .success: - break - case .cancelled: - throw CancellationError() - case let .failed(message): - biometricErrorMessage = message - showingBiometricError = true - throw CancellationError() - } - } else { - let shouldProceed = await requestPinCheck() - guard shouldProceed else { - throw CancellationError() - } - } - } - - try await performPayment() + try await submitPayment() } } .navigationBarHidden(true) @@ -180,6 +136,55 @@ struct LnurlPayConfirm: View { } } + private func submitPayment() async throws { + // Check if we need to show warning for amounts over $100 USD + if settings.warnWhenSendingOver100 { + let sats: UInt64 = if let invoice = app.scannedLightningInvoice { + wallet.sendAmountSats ?? invoice.amountSatoshis + } else { + 0 + } + + // Convert to USD to check if over $100 + if let usdAmount = currency.convert(sats: sats, to: "USD") { + if usdAmount.value > 100.0 { + showWarningAlert = true + // Wait for the alert to be dismissed + let shouldProceed = try await waitForAlertDismissal() + if !shouldProceed { + // User cancelled, throw error to reset SwipeButton + throw CancellationError() + } + // User confirmed, continue with authentication if needed + } + } + } + + // Check if authentication is required for payments + if settings.requirePinForPayments && settings.pinEnabled { + if settings.useBiometrics && BiometricAuth.isAvailable { + let result = await BiometricAuth.authenticate() + switch result { + case .success: + break + case .cancelled: + throw CancellationError() + case let .failed(message): + biometricErrorMessage = message + showingBiometricError = true + throw CancellationError() + } + } else { + let shouldProceed = await requestPinCheck() + guard shouldProceed else { + throw CancellationError() + } + } + } + + try await performPayment() + } + private func waitForAlertDismissal() async throws -> Bool { return try await withCheckedThrowingContinuation { continuation in alertContinuation = continuation @@ -194,6 +199,7 @@ struct LnurlPayConfirm: View { let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey + var bolt11Invoice: String? do { try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) @@ -206,6 +212,7 @@ struct LnurlPayConfirm: View { amountMsats: amountMsats, comment: comment.isEmpty ? nil : comment ) + bolt11Invoice = bolt11 let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) @@ -218,7 +225,7 @@ struct LnurlPayConfirm: View { sats: nil, onTimeout: { app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) - navigationPath.append(.pending(paymentHash: paymentHash)) + navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .lnurlPayConfirm)) } ) app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) @@ -230,13 +237,12 @@ struct LnurlPayConfirm: View { } catch { Logger.error("LNURL payment failed: \(error)") - // TODO: remove toast and use failure screen instead - app.toast(error) - - // TODO: this is a hack to make sure the navigation binding is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - navigationPath.append(.failure) - } + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .lnurlPayConfirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: bolt11Invoice ?? "LNURL: \(lnurlPayData.uri)" + ))) } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 46bf0e110..42a174069 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -16,6 +16,7 @@ struct SendConfirmationView: View { @Binding var navigationPath: [SendRoute] let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void + let routingCacheResetAttempted: Bool @State private var showDetails = false @State private var showingBiometricError = false @@ -163,38 +164,7 @@ struct SendConfirmationView: View { } SwipeButton(title: t("wallet__send_swipe"), accentColor: accentColor, swipeProgress: $swipeProgress) { - // Validate payment and show warnings if needed - let warnings = await validatePayment() - if !warnings.isEmpty { - let shouldProceed = try await showWarnings(warnings) - if !shouldProceed { - throw CancellationError() - } - } - - // Check if authentication is required for payments - if settings.requirePinForPayments && settings.pinEnabled { - if settings.useBiometrics && BiometricAuth.isAvailable { - let result = await BiometricAuth.authenticate() - switch result { - case .success: - break - case .cancelled: - throw CancellationError() - case let .failed(message): - biometricErrorMessage = message - showingBiometricError = true - throw CancellationError() - } - } else { - let shouldProceed = await requestPinCheck() - guard shouldProceed else { - throw CancellationError() - } - } - } - - try await performPayment() + try await submitPayment() } } .navigationBarHidden(true) @@ -473,6 +443,41 @@ struct SendConfirmationView: View { } } + private func submitPayment() async throws { + // Validate payment and show warnings if needed + let warnings = await validatePayment() + if !warnings.isEmpty { + let shouldProceed = try await showWarnings(warnings) + if !shouldProceed { + throw CancellationError() + } + } + + // Check if authentication is required for payments + if settings.requirePinForPayments && settings.pinEnabled { + if settings.useBiometrics && BiometricAuth.isAvailable { + let result = await BiometricAuth.authenticate() + switch result { + case .success: + break + case .cancelled: + throw CancellationError() + case let .failed(message): + biometricErrorMessage = message + showingBiometricError = true + throw CancellationError() + } + } else { + let shouldProceed = await requestPinCheck() + guard shouldProceed else { + throw CancellationError() + } + } + } + + try await performPayment() + } + private func contactRecipient(_ contact: PubkyContact) -> some View { HStack(spacing: 8) { PubkyContactAvatar(contact: contact, size: 24) @@ -515,7 +520,7 @@ struct SendConfirmationView: View { sats: paymentSats, onTimeout: { app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) - navigationPath.append(.pending(paymentHash: paymentHash)) + navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .confirm)) } ) await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) @@ -563,13 +568,12 @@ struct SendConfirmationView: View { try? await CoreService.shared.activity.deletePreActivityMetadata(paymentId: paymentId) } - // TODO: remove toast and use failure screen instead - app.toast(error) - - // TODO: this is a hack to make sure the navigation binding is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - navigationPath.append(.failure) - } + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .confirm, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: app.selectedWalletToPayFrom == .lightning ? app.scannedLightningInvoice?.bolt11 : nil + ))) } } diff --git a/Bitkit/Views/Wallets/Send/SendFailure.swift b/Bitkit/Views/Wallets/Send/SendFailure.swift index fa4580f77..829dd5b17 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -1,15 +1,90 @@ +import LDKNode import SwiftUI -// TODO: add error message and retry button +func sendFailureMessage(for error: Error) -> String { + let fallbackMessage = t("wallet__payment_failed_description") + + if let reason = (error as? AppError)?.paymentFailureReason { + return PaymentFailureReason.userMessage(for: reason) + } + + if let requestError = error as? PaykitPaymentRequestError { + return requestError.localizedDescription + } + + return fallbackMessage +} + +func shouldResetRoutingCachesOnRetry(for error: Error) -> Bool { + guard let reason = (error as? AppError)?.paymentFailureReason else { + return false + } + + return reason.shouldResetRoutingCachesOnRetry +} + +func sendFailureType(for error: Error) -> String { + if let reason = (error as? AppError)?.paymentFailureReason { + return compactFailureType(String(describing: reason)) + } + + if let requestError = error as? PaykitPaymentRequestError { + return compactFailureType(String(describing: requestError)) + } + + if let appError = error as? AppError, let underlyingError = appError.underlyingError { + return compactFailureType(String(describing: underlyingError)) + } + + return compactFailureType(String(describing: error)) +} + +private func compactFailureType(_ value: String) -> String { + var result = value + + if result.hasPrefix("Optional("), result.hasSuffix(")") { + result = String(result.dropFirst("Optional(".count).dropLast()) + } + + if let parenthesisIndex = result.firstIndex(of: "(") { + result = String(result[.. Void + + private var title: String { + switch context.retryRoute { + case .confirm: + return app.selectedWalletToPayFrom == .lightning ? t("wallet__send_instant_failed") : t("wallet__send_error_tx_failed") + case .quickpay, .lnurlPayConfirm: + return t("wallet__send_instant_failed") + } + } var body: some View { VStack(alignment: .leading, spacing: 0) { ZStack { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__send_error_tx_failed"), showBackButton: false) + SheetHeader(title: title, showBackButton: false) + .accessibilityIdentifier("SendFailure") + + BodyMText(context.message ?? t("wallet__payment_failed_description")) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SendFailureMessage") Spacer() @@ -22,10 +97,23 @@ struct SendFailure: View { Spacer() - HStack(spacing: 16) { - CustomButton(title: t("common__close")) { - sheets.hideSheet() + VStack(spacing: 16) { + CustomButton( + title: t("wallet__send_error_support"), + variant: .secondary, + isDisabled: wallet.isRetryingLightningPayment + ) { + contactSupport() + } + .accessibilityIdentifier("Support") + + CustomButton( + title: t("common__try_again"), + isLoading: wallet.isRetryingLightningPayment + ) { + retryPayment() } + .accessibilityIdentifier("Retry") } } .padding(.horizontal, 16) @@ -35,4 +123,73 @@ struct SendFailure: View { .sheetBackground() } } + + private func retryPayment() { + guard context.resetRoutingCachesOnRetry else { + onRetryReady(false) + return + } + + guard !wallet.isRetryingLightningPayment else { return } + wallet.isRetryingLightningPayment = true + + Task { @MainActor in + defer { + wallet.isRetryingLightningPayment = false + } + + do { + var cacheResetError: Error? + do { + try await wallet.resetPaymentRoutingCaches() + } catch { + cacheResetError = error + } + + try await wallet.start() + let refreshStartedAt = Date() + + if let cacheResetError { + throw cacheResetError + } + + try await wallet.waitForPaymentRoutingDataRefresh(startedAt: refreshStartedAt) + onRetryReady(true) + } catch { + Logger.error("Failed to reset routing caches before payment retry: \(error)", context: "SendFailure") + app.toast(error) + } + } + } + + private func contactSupport() { + sheets.hideSheet() + navigation.navigate(.reportIssue(ReportIssuePrefill(message: supportMessage()))) + } + + private func supportMessage() -> String { + return """ + I need help with a failed send payment. + + Failure type: \(context.failureType) + Payment method: \(app.selectedWalletToPayFrom) + Routing cache reset attempted: \(context.routingCacheResetAttempted ? "Yes" : "No") + + Payment request: \(context.paymentRequest ?? supportPaymentRequest()) + + Please investigate this payment failure. + """ + } + + private func supportPaymentRequest() -> String { + if let invoice = app.scannedLightningInvoice { + return invoice.bolt11 + } + + if let lnurlPayData = app.lnurlPayData { + return "LNURL: \(lnurlPayData.uri)" + } + + return "Unavailable" + } } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 481b7191b..697780722 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -25,6 +25,7 @@ struct HourglassLoadingView: View { struct SendPendingScreen: View { let paymentHash: String + let retryRoute: SendRetryRoute @Binding var navigationPath: [SendRoute] @EnvironmentObject private var activityList: ActivityListViewModel @@ -87,7 +88,11 @@ struct SendPendingScreen: View { } } else { app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) - navigationPath.append(.failure) + navigationPath.append(.failure(SendFailureContext( + message: t("wallet__payment_failed_description"), + retryRoute: retryRoute, + resetRoutingCachesOnRetry: false + ))) } } } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index e23c1580a..5c39f51a7 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -7,6 +7,7 @@ struct SendQuickpay: View { @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] + let routingCacheResetAttempted: Bool var body: some View { VStack { @@ -40,9 +41,9 @@ struct SendQuickpay: View { } private func performPayment() async { - do { - var bolt11Invoice: String? + var bolt11Invoice: String? + do { // Handle LNURL Pay if let lnurlPayData = app.lnurlPayData { // Set the amount in sats for the success screen @@ -73,7 +74,7 @@ struct SendQuickpay: View { sats: nil, onTimeout: { app.addPendingPaymentHash(paymentHash) - navigationPath.append(.pending(paymentHash: paymentHash)) + navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay)) } ) Logger.info("Quickpay payment successful: \(paymentHash)") @@ -82,19 +83,18 @@ struct SendQuickpay: View { // onTimeout callback already navigated to .pending; suppress throw return } catch { - handlePaymentError(error) + handlePaymentError(error, paymentRequest: bolt11Invoice) } } - private func handlePaymentError(_ error: Error) { + private func handlePaymentError(_ error: Error, paymentRequest: String?) { Logger.error("Quickpay payment failed: \(error)") - // TODO: remove toast and use failure screen instead - app.toast(error) - - // TODO: this is a hack to make sure the navigation binding is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - navigationPath.append(.failure) - } + navigationPath.append(.failure(SendFailureContext( + error: error, + retryRoute: .quickpay, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: paymentRequest + ))) } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index a6ae0c0c9..17984993c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -1,5 +1,55 @@ import SwiftUI +enum SendRetryRoute: Hashable { + case confirm + case quickpay + case lnurlPayConfirm + + var sendRoute: SendRoute { + switch self { + case .confirm: .confirm + case .quickpay: .quickpay + case .lnurlPayConfirm: .lnurlPayConfirm + } + } +} + +struct SendFailureContext: Hashable { + let message: String? + let retryRoute: SendRetryRoute + let resetRoutingCachesOnRetry: Bool + let failureType: String + let paymentRequest: String? + let routingCacheResetAttempted: Bool + + init( + message: String?, + retryRoute: SendRetryRoute, + resetRoutingCachesOnRetry: Bool, + failureType: String = "Unknown", + paymentRequest: String? = nil, + routingCacheResetAttempted: Bool = false + ) { + self.message = message + self.retryRoute = retryRoute + self.resetRoutingCachesOnRetry = resetRoutingCachesOnRetry + self.failureType = failureType + self.paymentRequest = paymentRequest + self.routingCacheResetAttempted = routingCacheResetAttempted + } + + init(error: Error, retryRoute: SendRetryRoute, routingCacheResetAttempted: Bool = false, paymentRequest: String? = nil) { + let shouldResetRoutingCaches = shouldResetRoutingCachesOnRetry(for: error) + + message = sendFailureMessage(for: error) + self.retryRoute = retryRoute + resetRoutingCachesOnRetry = shouldResetRoutingCaches && !routingCacheResetAttempted + failureType = sendFailureType(for: error) + self.paymentRequest = paymentRequest + self.routingCacheResetAttempted = routingCacheResetAttempted + } +} + enum SendRoute: Hashable { case options case contact @@ -13,9 +63,9 @@ enum SendRoute: Hashable { case tag case quickpay case pin - case pending(paymentHash: String) + case pending(paymentHash: String, retryRoute: SendRetryRoute) case success(paymentId: String) - case failure + case failure(SendFailureContext) case lnurlPayAmount case lnurlPayConfirm case lnurlWithdrawAmount @@ -54,6 +104,7 @@ struct SendSheet: View { @State private var navigationPath: [SendRoute] = [] @State private var hasValidatedAfterSync = false + @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] @@ -421,7 +472,8 @@ struct SendSheet: View { SendConfirmationView( navigationPath: $navigationPath, requestPinCheck: requestPinCheck, - prepareIncomingPaymentRequest: prepareIncomingPaymentRequest + prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, + routingCacheResetAttempted: routingCacheResetAttempted ) case .feeRate: SendFeeRate(navigationPath: $navigationPath) @@ -430,22 +482,31 @@ struct SendSheet: View { case .tag: SendTagScreen(navigationPath: $navigationPath) case .quickpay: - SendQuickpay(navigationPath: $navigationPath) + SendQuickpay(navigationPath: $navigationPath, routingCacheResetAttempted: routingCacheResetAttempted) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) - case let .pending(paymentHash): - SendPendingScreen(paymentHash: paymentHash, navigationPath: $navigationPath) + case let .pending(paymentHash, retryRoute): + SendPendingScreen(paymentHash: paymentHash, retryRoute: retryRoute, navigationPath: $navigationPath) case let .success(paymentId): SendSuccess(paymentId: paymentId) - case .failure: - SendFailure() + case let .failure(context): + SendFailure( + context: context, + onRetryReady: { didResetRoutingCaches in + if didResetRoutingCaches { + routingCacheResetAttempted = true + } + resetNavigationForRetry(context.retryRoute) + } + ) case .lnurlPayAmount: LnurlPayAmount(navigationPath: $navigationPath) case .lnurlPayConfirm: LnurlPayConfirm( navigationPath: $navigationPath, requestPinCheck: requestPinCheck, - prepareIncomingPaymentRequest: prepareIncomingPaymentRequest + prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, + routingCacheResetAttempted: routingCacheResetAttempted ) case .lnurlWithdrawAmount: LnurlWithdrawAmount { @@ -473,6 +534,11 @@ struct SendSheet: View { ) } } + + private func resetNavigationForRetry(_ retryRoute: SendRetryRoute) { + let route = retryRoute.sendRoute + navigationPath = route == config.initialRoute ? [] : [route] + } } private struct SendComingSoonView: View { diff --git a/BitkitTests/SavingsSwapTests.swift b/BitkitTests/SavingsSwapTests.swift index 0f7aed361..0720817a2 100644 --- a/BitkitTests/SavingsSwapTests.swift +++ b/BitkitTests/SavingsSwapTests.swift @@ -1,9 +1,8 @@ +@testable import Bitkit import BitkitCore import LDKNode import XCTest -@testable import Bitkit - final class SavingsSwapTests: XCTestCase { // MARK: - Quote math @@ -91,33 +90,33 @@ final class SavingsSwapTests: XCTestCase { // MARK: - Payment failure messages - func testPaymentFailureReasonsMapToTheirUserMessages() { + func testPaymentFailureReasonsMapToSendFailureMessages() { XCTAssertEqual( - PaymentFailureReason.userMessage(for: .recipientRejected), - t("wallet__toast_payment_failed_recipient_rejected") + AppError(paymentFailureReason: .recipientRejected).localizedDescription, + t("wallet__payment_recipient_rejected") ) XCTAssertEqual( - PaymentFailureReason.userMessage(for: .retriesExhausted), - t("wallet__toast_payment_failed_retries_exhausted") + AppError(paymentFailureReason: .retriesExhausted).localizedDescription, + t("wallet__payment_retries_exhausted") ) XCTAssertEqual( - PaymentFailureReason.userMessage(for: .routeNotFound), - t("wallet__toast_payment_failed_route_not_found") + AppError(paymentFailureReason: .routeNotFound).localizedDescription, + t("wallet__payment_route_not_found") ) XCTAssertEqual( - PaymentFailureReason.userMessage(for: .paymentExpired), - t("wallet__toast_payment_failed_timeout") + AppError(paymentFailureReason: .paymentExpired).localizedDescription, + t("wallet__payment_expired") ) } func testUnmappedAndAbsentPaymentFailureReasonsFallBackToTheGenericMessage() { XCTAssertEqual( - PaymentFailureReason.userMessage(for: .unexpectedError), - t("wallet__toast_payment_failed_description") + AppError(paymentFailureReason: .unexpectedError).localizedDescription, + t("wallet__payment_failed_description") ) XCTAssertEqual( - PaymentFailureReason.userMessage(for: nil), - t("wallet__toast_payment_failed_description") + AppError(paymentFailureReason: nil).localizedDescription, + t("wallet__payment_failed_description") ) } diff --git a/changelog.d/next/652.added.md b/changelog.d/next/652.added.md new file mode 100644 index 000000000..704b7a84d --- /dev/null +++ b/changelog.d/next/652.added.md @@ -0,0 +1 @@ +Improved Lightning send failures with clearer localized messages and a retry action that refreshes payment routing before trying again.