diff --git a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift index 21beed876..ac6944a50 100644 --- a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift +++ b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift @@ -15,6 +15,7 @@ // limitations under the License. // +import Intents import UIKit import MessageUI import SwiftDashSDK @@ -137,7 +138,21 @@ extension UIViewController { present(mailComposer, animated: true) } else { - var activityItems: [Any] = logFiles + // No Apple Mail account configured (common when the customer lives in Gmail), so + // `MFMailComposeViewController` is unavailable and the logs go out through the share + // sheet instead. Lead with a mail item carrying the support address and subject: + // handlers that understand `mailto:` prefill the recipient from it, and the rest at + // least show the address in the composed body — the previous items were log files + // only, which is why reports arrived with an empty "To" field. + let email = Bundle.main.infoDictionary?["SupportEmail"] as? String ?? "" + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let subject = String(format: NSLocalizedString("iOS Dash Wallet: %@ Reported issue", comment: ""), version) + + var activityItems: [Any] = [] + if !email.isEmpty { + activityItems.append(SupportRecipientActivityItem(email: email, subject: subject)) + } + activityItems.append(contentsOf: logFiles) if let sdkLogsZip { activityItems.append(sdkLogsZip) } @@ -187,3 +202,60 @@ extension UIViewController { present(activityViewController, animated: true, completion: completion) } } + +/// Carries the support destination into the share sheet used when `MFMailComposeViewController` +/// is unavailable. Recipients travel three ways, because no single one reaches every handler: +/// `activityViewControllerShareRecipients(_:)` for extensions that read recipient metadata, a +/// `mailto:` URL for mail activities that parse it, and plain text for everything else, where the +/// address at least stays visible in the composed message. +final class SupportRecipientActivityItem: NSObject, UIActivityItemSource { + private let email: String + private let subject: String + + init(email: String, subject: String) { + self.email = email + self.subject = subject + super.init() + } + + private var mailtoURL: URL? { + var components = URLComponents() + components.scheme = "mailto" + components.path = email + components.queryItems = [URLQueryItem(name: "subject", value: subject)] + return components.url + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + mailtoURL ?? email + } + + func activityViewController( + _ activityViewController: UIActivityViewController, + itemForActivityType activityType: UIActivity.ActivityType? + ) -> Any? { + guard let mailtoURL else { return email } + return activityType == .mail ? mailtoURL : email + } + + func activityViewController( + _ activityViewController: UIActivityViewController, + subjectForActivityType activityType: UIActivity.ActivityType? + ) -> String { + subject + } + + /// Pre-fills the recipient in handlers that support it. The system calls this on the main + /// thread while presenting, so it stays a plain value construction. + func activityViewControllerShareRecipients( + _ activityViewController: UIActivityViewController + ) -> [INPerson] { + let handle = INPersonHandle(value: email, type: .emailAddress) + return [INPerson(personHandle: handle, + nameComponents: nil, + displayName: email, + image: nil, + contactIdentifier: nil, + customIdentifier: nil)] + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift index 555d38b1c..994ea77ae 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift @@ -30,7 +30,17 @@ final class SwiftDashSDKWalletSending: WalletSending { "PreparedSend carries no SDK transaction handle") } let outcome = try SwiftDashSDKTransactionSender.broadcast(tx) - _ = try SwiftDashSDKTransactionSender.requireAccepted(outcome) + do { + _ = try SwiftDashSDKTransactionSender.requireAccepted(outcome) + } catch SwiftDashSDKTransactionSender.SendError.transactionStatusUnknown(_, let reason) { + // "Unknown" is not "failed": the SDK's acceptance detector only watches for a + // relay-back from the withheld peer, so a transaction that is already in the + // mempool (and even InstantLocked) lands here whenever no peer echoes it back in + // time. Carry the app-computed tx hash out with the error so the caller can + // record the spend it just made instead of losing it. Rejections keep throwing + // `SendError.transactionRejected` unchanged. + throw BIP70Error.broadcastOutcomeUnknown(txHashDisplay: prepared.txHashDisplay, reason: reason) + } // Return contract is the display-order txid hex; keep the deterministic // app-computed value (the sender logs the SDK-reported txid alongside). return prepared.txHashDisplay.map { String(format: "%02x", $0) }.joined() diff --git a/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift b/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift index f36239e8d..d9a24aa28 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift @@ -35,6 +35,14 @@ enum DashSpendError: Error, LocalizedError { case previousSwapPending case swapAwaitingInstantLock case authenticationCancelled + /// The Dash payment was broadcast but the network returned no acceptance verdict in time. + /// The order is NOT lost: the merchant holds the signed transaction and the gift card is + /// recorded against `txIdWire` so the details poller can pick it up once it fulfils. + case paymentStatusUnknown(txIdWire: Data, reason: String) + /// CTX never acknowledged the payment, so it is unknown whether the order was received at + /// all. The purchase is still recorded against `txIdWire`: if CTX did receive the signed + /// transaction it broadcasts it and fulfils the order, and the card must not be lost. + case paymentNotAcknowledged(txIdWire: Data, reason: String) var errorDescription: String? { switch self { @@ -68,6 +76,16 @@ enum DashSpendError: Error, LocalizedError { return message case .unknown: return NSLocalizedString("An unknown error occurred. Please try again later.", comment: "DashSpend") + case .paymentNotAcknowledged: + return NSLocalizedString( + "We could not confirm this purchase with CTX. If the payment went through, the gift card will appear in your transaction list shortly — please check there before buying again.", + comment: "DashSpend" + ) + case .paymentStatusUnknown: + return NSLocalizedString( + "Your payment was sent, but the network has not confirmed it yet. Your gift card will appear as soon as it is confirmed.", + comment: "DashSpend" + ) case .paymentProcessingError(let details): return String(format: NSLocalizedString("Payment processing error: %@", comment: "DashSpend"), details) case .previousSwapPending: diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift index 415f0fe3c..865d8db8c 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift @@ -39,6 +39,17 @@ enum BIP70Error: Error, Equatable { case authCancelled /// A second send was attempted on a `Confirmation` that has already been (or is being) sent. case alreadySent + /// The Payment was posted but no acknowledgement came back. The merchant may or may not + /// have received the signed bytes — BIP70 cannot tell those apart — so this carries the + /// app-computed display-order tx hash: if the merchant did receive them it can broadcast + /// independently, and the caller needs a handle on the spend it may have just made. + case paymentNotAcknowledged(txHashDisplay: Data, reason: String) + /// The transaction was submitted but the network returned no positive acceptance verdict + /// within the SDK's wait window. The bytes may already be propagating — and the merchant + /// holds a copy it can broadcast itself — so this is deliberately distinct from a + /// rejection: it carries the app-computed display-order tx hash so callers can record the + /// spend instead of discarding it. Never retry the same send on this error. + case broadcastOutcomeUnknown(txHashDisplay: Data, reason: String) } extension BIP70Error: LocalizedError { @@ -58,6 +69,8 @@ extension BIP70Error: LocalizedError { case .walletNotReady: return "The wallet isn't ready yet. Please try again in a moment." case .authCancelled: return "Authentication was cancelled." case .alreadySent: return "This payment is already being processed." + case .paymentNotAcknowledged: return "The merchant did not confirm receiving the payment." + case .broadcastOutcomeUnknown: return "Your payment was sent, but the network has not confirmed it yet." } } } diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift index 19c6bb45c..7b8cc5f2d 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift @@ -261,7 +261,15 @@ final class BIP70PaymentService { /// broadcast — build/sign failures and POST/ACK failures. Once broadcast is attempted the /// guard is never reset: the merchant holds the signed bytes and may broadcast them /// independently, so a retry would rebuild a conflicting spend of the same inputs. - func confirmAndSend(_ confirmation: Confirmation, now: Date = Date()) async throws -> SendResult { + /// - Parameter awaitAcceptance: `true` (the default) blocks until the SDK returns a + /// network-acceptance verdict, so the caller can report a rejection. `false` returns as + /// soon as the merchant has acknowledged the Payment and the broadcast has been handed + /// to the SDK, leaving the verdict to resolve in the background — for flows (gift cards) + /// where the merchant fulfils the order from its own copy of the signed bytes and the + /// verdict changes nothing the user is waiting for. + func confirmAndSend(_ confirmation: Confirmation, + now: Date = Date(), + awaitAcceptance: Bool = true) async throws -> SendResult { // 1. Expiry re-check at send time (the user may have lingered on the confirm screen). try Self.assertNotExpired(confirmation.request.details.expires, now: now) @@ -304,12 +312,41 @@ final class BIP70PaymentService { ackMemo = ack.memo } catch { confirmation.sendGuard.reset() - throw (error as? BIP70Error) ?? BIP70Error.ackRejected + // Carry the tx hash out with the failure. The caveat above is not hypothetical: + // losing the network between the POST and its response leaves the merchant + // holding the signed bytes, fulfilling the order and broadcasting them, while + // the caller has nothing to record the purchase against. + throw BIP70Error.paymentNotAcknowledged( + txHashDisplay: prepared.txHashDisplay, + reason: (error as? BIP70Error)?.errorDescription ?? error.localizedDescription) } } // 6. Broadcast. From this point the guard is never reset — see the invariant above. - let txidHexDisplay = try await wallet.broadcast(prepared) + // Skipping the verdict is only defensible because the merchant's acknowledgement already + // committed the spend. An unsigned request without a `payment_url` never posts a Payment, + // so nothing has committed anything and the broadcast outcome is the only signal there is. + // Reaching here with a payment URL means the ACK decoded — a failure would have thrown. + let acknowledged = confirmation.paymentURL != nil + + let txidHexDisplay: String + if awaitAcceptance || !acknowledged { + txidHexDisplay = try await wallet.broadcast(prepared) + } else { + // The merchant already acknowledged the signed bytes, so the spend is committed + // whatever the network verdict turns out to be. Hand the broadcast off and report + // the app-computed txid now: the caller records the purchase against it and the + // verdict only decides what gets logged here. + txidHexDisplay = prepared.txHashDisplay.map { String(format: "%02x", $0) }.joined() + let wallet = self.wallet + Task.detached(priority: .userInitiated) { + do { + _ = try await wallet.broadcast(prepared) + } catch { + DWLogger.log("BIP70: background broadcast of \(txidHexDisplay) ended without acceptance: \(error)") + } + } + } let callbackURL = Self.makeCallbackURL(scheme: confirmation.callbackScheme, address: confirmation.primaryAddress, @@ -328,11 +365,12 @@ final class BIP70PaymentService { scheme: String, network: PaymentNetwork, callbackScheme: String? = nil, - now: Date = Date()) async throws -> SendResult { + now: Date = Date(), + awaitAcceptance: Bool = true) async throws -> SendResult { try await auth.authorize() let confirmation = try await prepareForConfirmation(from: requestURL, scheme: scheme, network: network, callbackScheme: callbackScheme, now: now) - return try await confirmAndSend(confirmation, now: now) + return try await confirmAndSend(confirmation, now: now, awaitAcceptance: awaitAcceptance) } // MARK: Helpers diff --git a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift index de91263d0..cdd5ca97f 100644 --- a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift +++ b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift @@ -77,15 +77,34 @@ public final class SendCoinsService: NSObject { /// /// - Returns: the wire-order txid of the broadcast transaction /// (`Transaction.txHashData` convention — the caller's metadata key). - func payWithDashUrl(url paymentUrlString: String) async throws -> Data { + /// - Parameter awaitAcceptance: `false` returns once the merchant has acknowledged the + /// Payment and the broadcast is under way, without waiting out the SDK's network-acceptance + /// window (30 s). The gift-card flow uses it: CTX fulfils the order from the bytes it just + /// acknowledged, so the verdict decides nothing the buyer is standing by for. + func payWithDashUrl(url paymentUrlString: String, awaitAcceptance: Bool = true) async throws -> Data { guard let uri = BIP70URI(paymentUrlString), let requestURL = uri.r else { throw DashSpendError.paymentProcessingError("Invalid payment request") } let network = try PaymentNetworkResolver.current() let service = BIP70PaymentService.makeForCurrentWallet() - let result = try await service.confirmAndSendHeadless( - from: requestURL, scheme: uri.scheme, network: network, callbackScheme: uri.callbackScheme) + let result: SendResult + do { + result = try await service.confirmAndSendHeadless( + from: requestURL, scheme: uri.scheme, network: network, + callbackScheme: uri.callbackScheme, awaitAcceptance: awaitAcceptance) + } catch BIP70Error.paymentNotAcknowledged(let txHashDisplay, let reason) { + // The merchant may already hold the signed bytes; hand the caller the txid so the + // order is recorded rather than dropped on the floor. + throw DashSpendError.paymentNotAcknowledged( + txIdWire: Data(txHashDisplay.reversed()), reason: reason) + } catch BIP70Error.broadcastOutcomeUnknown(let txHashDisplay, let reason) { + // The coins are gone as far as the merchant is concerned — it already holds the + // signed bytes and can broadcast them itself. Hand the caller the txid so the + // purchase is recorded rather than dropped; the caller decides how to present it. + throw DashSpendError.paymentStatusUnknown( + txIdWire: Data(txHashDisplay.reversed()), reason: reason) + } let txidWire = Data(result.txHashDisplay.reversed()) diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayScreen.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayScreen.swift index 3c7aa0992..f3ccc420b 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayScreen.swift @@ -219,6 +219,15 @@ struct DashSpendPayScreen: View { showConfirmationDialog = false presentationMode.wrappedValue.dismiss() onPurchaseSuccess?(txId) + } catch DashSpendError.paymentStatusUnknown(let txId, let reason) { + // Paid, order placed, acceptance verdict missing. The purchase is already + // recorded, so route to the card details exactly like a confirmed purchase — + // its poller is what turns the pending order into a card. Reporting "Purchase + // Failed" here is what previously left customers paying for an invisible card. + DWLogger.log("Gift card purchase pending network confirmation: \(reason)") + showConfirmationDialog = false + presentationMode.wrappedValue.dismiss() + onPurchaseSuccess?(txId) } catch let error as DashSpendError { showConfirmationDialog = false errorTitle = NSLocalizedString("Purchase Failed", comment: "DashSpend") diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift index ac6c812f1..4a75fe24c 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift @@ -306,8 +306,29 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand giftCardNote = response.id - // CTX uses BIP70 payment request URLs - txidWire = try await sendCoinsService.payWithDashUrl(url: url) + // CTX uses BIP70 payment request URLs. The acceptance verdict is not awaited: CTX + // acknowledges the signed transaction and starts fulfilling the order from it, so + // blocking the buyer for the SDK's 30 s acceptance window only delays the card that + // is already being issued (the email routinely arrived before the screen moved on). + do { + txidWire = try await sendCoinsService.payWithDashUrl(url: url, awaitAcceptance: false) + } catch DashSpendError.paymentNotAcknowledged(let unacknowledgedTxIdWire, let reason) { + // Same loss, one step earlier: the network dropped between posting the payment + // and reading the acknowledgement. Record the order — CTX may be fulfilling it + // already — but keep the error, since we genuinely cannot say it was received. + DWLogger.log("Gift card payment unacknowledged, recording the order anyway: \(reason)") + recordPurchase(txidWire: unacknowledgedTxIdWire, giftCardNote: giftCardNote) + throw DashSpendError.paymentNotAcknowledged( + txIdWire: unacknowledgedTxIdWire, reason: reason) + } catch DashSpendError.paymentStatusUnknown(let unconfirmedTxIdWire, let reason) { + // The payment left the device and CTX holds the signed bytes; only the + // network's acceptance verdict is missing. Record the purchase against the + // txid we do have so the order survives — without this the customer pays, + // CTX fulfils the order, and the app keeps no trace of the card at all. + DWLogger.log("Gift card payment status unknown, recording the order anyway: \(reason)") + recordPurchase(txidWire: unconfirmedTxIdWire, giftCardNote: giftCardNote) + throw DashSpendError.paymentStatusUnknown(txIdWire: unconfirmedTxIdWire, reason: reason) + } #if PIGGYCARDS_ENABLED case .piggyCards: @@ -354,11 +375,18 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand } // Payment successful - save gift card information + recordPurchase(txidWire: txidWire, giftCardNote: giftCardNote) + + return txidWire + } + + /// Persist everything that ties a paid order to its transaction: the tax/service metadata, + /// the merchant icon, and the `gift_cards` row whose `note` carries the order id the + /// details poller needs. Shared by the confirmed and the unconfirmed-broadcast paths. + private func recordPurchase(txidWire: Data, giftCardNote: String) { markGiftCardTransaction(txId: txidWire, provider: provider.displayName) customIconProvider.updateIcon(txId: txidWire, iconUrl: merchantIconUrl) saveGiftCardDummy(txHashData: txidWire, giftCardNote: giftCardNote) - - return txidWire } var contactSupportButtonText: String { diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardDetailsInfoCard.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardDetailsInfoCard.swift index c6323f533..b6d8e904d 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardDetailsInfoCard.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardDetailsInfoCard.swift @@ -24,6 +24,8 @@ struct GiftCardDetailsInfoCard: View { let isLoadingCardDetails: Bool let hasBeenPollingForLongTime: Bool let loadingError: Error? + /// Offered when the poller gave up on a run of failures; nil hides the affordance. + var onRetryLoading: (() -> Void)? = nil let onOpenClaimLink: (String) -> Void let onCopy: (String) -> Void @@ -67,9 +69,19 @@ struct GiftCardDetailsInfoCard: View { .scaleEffect(0.8) } } else if loadingError != nil { - Text(NSLocalizedString("Failed to load barcode", comment: "DashSpend")) - .font(.footnote) - .foregroundColor(.dash.red) + VStack(spacing: 6) { + Text(NSLocalizedString("Failed to load barcode", comment: "DashSpend")) + .font(.footnote) + .foregroundColor(.dash.red) + + if let onRetryLoading { + Button(action: onRetryLoading) { + Text(NSLocalizedString("Retry", comment: "DashSpend")) + .font(.footnote.weight(.medium)) + .foregroundColor(.dash.blue) + } + } + } } } .padding(.horizontal, 6) diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardPurchaseSelectionSheet.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardPurchaseSelectionSheet.swift index 189af4811..5c00fd27a 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardPurchaseSelectionSheet.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/Components/GiftCardPurchaseSelectionSheet.swift @@ -12,6 +12,11 @@ struct GiftCardPurchaseSelectionSheet: View { let cards: [GiftCardDetailsCardItem] let isLoadingCardDetails: Bool let hasBeenPollingForLongTime: Bool + /// Set once the poller gave up on a run of failures — without it this sheet keeps promising + /// a card that nothing is fetching any more. + var loadingError: Error? = nil + /// Offered alongside `loadingError`; nil hides the affordance. + var onRetryLoading: (() -> Void)? = nil let onSelectCard: (Int) -> Void @@ -50,6 +55,22 @@ struct GiftCardPurchaseSelectionSheet: View { SwiftUI.ProgressView() .progressViewStyle(CircularProgressViewStyle()) .scaleEffect(0.9) + } else if loadingError != nil { + VStack(spacing: 6) { + Text(NSLocalizedString("Could not load your gift card", comment: "DashSpend")) + .font(.footnote) + .foregroundColor(.dash.red) + .multilineTextAlignment(.center) + + if let onRetryLoading { + Button(action: onRetryLoading) { + Text(NSLocalizedString("Retry", comment: "DashSpend")) + .font(.footnote.weight(.medium)) + .foregroundColor(.dash.blue) + } + } + } + .padding(.horizontal, 24) } else if hasBeenPollingForLongTime { Text(NSLocalizedString("As soon as your code is generated, it will be displayed here", comment: "DashSpend")) .font(.footnote) diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsView.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsView.swift index fe9a82de7..3c0e4e08f 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsView.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsView.swift @@ -112,6 +112,7 @@ struct GiftCardDetailsView: View { isLoadingCardDetails: viewModel.uiState.isLoadingCardDetails, hasBeenPollingForLongTime: viewModel.uiState.hasBeenPollingForLongTime, loadingError: viewModel.uiState.loadingError, + onRetryLoading: viewModel.uiState.canRetryLoading ? { viewModel.retryLoadingCardDetails() } : nil, onOpenClaimLink: openClaimLink, onCopy: copyToPasteboard ) diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsViewModel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsViewModel.swift index 45f0368d0..3f7d7ad7d 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/GiftCardDetails/GiftCardDetailsViewModel.swift @@ -44,6 +44,8 @@ struct GiftCardDetailsUIState { var transaction: Transaction? = nil var isClaimLink: Bool = false var hasBeenPollingForLongTime: Bool = false + /// The poller stopped on a run of failures and the sheet may offer to start it again. + var canRetryLoading: Bool = false var provider: String? = nil var cards: [GiftCardDetailsCardItem] = [] } @@ -73,9 +75,19 @@ class GiftCardDetailsViewModel: ObservableObject { private lazy var customIconDAO = IconBitmapDAOImpl.shared private lazy var txMetadataDAO = TransactionMetadataDAOImpl.shared private var tickerTimer: Timer? - private var retryCount = 0 - private let maxRetries = 40 + /// Polls served since the ticker started, successful or not — drives the "still working on + /// it" copy. Counting failures alone (as this once did) never reached the threshold while + /// CTX was healthily answering "not fulfilled yet", so the hint never appeared. + private var pollCount = 0 + /// Consecutive failed polls. Reset by any answered poll; drives the backoff and the give-up. + private var errorStreak = 0 + /// Give up after this many consecutive failures. With the backoff below that is ~2 minutes + /// of a CTX outage before the retry affordance is offered, instead of hammering it. + private let maxErrorStreak = 8 private let longPollingThreshold = 27 + private let basePollInterval: TimeInterval = 1.5 + private let maxPollInterval: TimeInterval = 30 + private var pollInterval: TimeInterval = 1.5 let txId: Data @Published private(set) var uiState = GiftCardDetailsUIState() @@ -194,28 +206,58 @@ class GiftCardDetailsViewModel: ObservableObject { } private func startTicker() { - guard tickerTimer == nil else { return } + // `isLoadingCardDetails` is the synchronous claim: the first poll is awaited before a + // timer exists, so guarding on `tickerTimer` alone would let a second `loadGiftCard` + // (the DAO publisher fires on every write) start a parallel poller in that window. + guard tickerTimer == nil, !uiState.isLoadingCardDetails else { return } uiState.isLoadingCardDetails = true uiState.loadingError = nil + uiState.canRetryLoading = false Task { await fetchGiftCardInfo() + scheduleNextPoll() } + } - tickerTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - Task { [weak self] in + /// Re-arms a single-shot timer at the current interval. Single-shot rather than repeating so + /// a failing CTX backs off (doubling up to `maxPollInterval`) instead of being polled every + /// 1.5 s for as long as the sheet is open. + private func scheduleNextPoll() { + guard uiState.isLoadingCardDetails else { return } + + tickerTimer?.invalidate() + tickerTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in await self?.fetchGiftCardInfo() + self?.scheduleNextPoll() } } } + private func noteSuccessfulPoll() { + errorStreak = 0 + pollInterval = basePollInterval + } + + /// Resume polling after the ticker gave up on a run of failures (the "Retry" affordance). + func retryLoadingCardDetails() { + guard tickerTimer == nil else { return } + + errorStreak = 0 + pollInterval = basePollInterval + startTicker() + } + private func stopTicker() { tickerTimer?.invalidate() tickerTimer = nil uiState.isLoadingCardDetails = false uiState.hasBeenPollingForLongTime = false - retryCount = 0 + pollCount = 0 + errorStreak = 0 + pollInterval = basePollInterval } private func fetchGiftCardInfo() async { @@ -225,7 +267,8 @@ class GiftCardDetailsViewModel: ObservableObject { return } - if retryCount >= longPollingThreshold { + pollCount += 1 + if pollCount >= longPollingThreshold { await MainActor.run { self.uiState.hasBeenPollingForLongTime = true } @@ -262,6 +305,10 @@ class GiftCardDetailsViewModel: ObservableObject { response = try await ctxSpendRepository.getGiftCardByTxid(txid: base58TxId) } + // An answered poll — whatever the order status — ends the failure streak and + // returns the ticker to its base cadence. + noteSuccessfulPoll() + switch response.status { case "fulfilled": if let cardNumber = response.cardNumber, !cardNumber.isEmpty { @@ -304,14 +351,20 @@ class GiftCardDetailsViewModel: ObservableObject { break } } catch { - retryCount += 1 - if retryCount >= maxRetries { + errorStreak += 1 + // Read before the give-up path: `stopTicker` resets the streak, so logging it + // afterwards reported the last failure as attempt 0. + let attempt = errorStreak + if errorStreak >= maxErrorStreak { await MainActor.run { self.uiState.loadingError = error + self.uiState.canRetryLoading = true } stopTicker() + } else { + pollInterval = min(pollInterval * 2, maxPollInterval) } - DWLogger.log("DashSpend: Failed to fetch gift card info: \(error)") + DWLogger.log("DashSpend: Failed to fetch gift card info (attempt \(attempt)): \(error)") } } @@ -328,6 +381,7 @@ class GiftCardDetailsViewModel: ObservableObject { do { DWLogger.log("DashSpend: Calling PiggyCards API - OrderId: \(metadata.orderId)") let orderStatus = try await piggyCardsRepository.getOrderStatus(orderId: metadata.orderId) + noteSuccessfulPoll() switch orderStatus.data.status.lowercased() { case "complete", "completed": @@ -384,12 +438,15 @@ class GiftCardDetailsViewModel: ObservableObject { break } } catch { - retryCount += 1 - if retryCount >= maxRetries { + errorStreak += 1 + if errorStreak >= maxErrorStreak { await MainActor.run { self.uiState.loadingError = error + self.uiState.canRetryLoading = true } stopTicker() + } else { + pollInterval = min(pollInterval * 2, maxPollInterval) } } } diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 2a685a4d0..29a2c67ab 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -228,7 +228,6 @@ struct HomeViewContent: View { @State private var shouldShowJoinDashPayInfo: Bool = false @State private var navigateToDashPayFlow: Bool = false @State private var navigateToClaimInvitation: Bool = false - @State private var giftCardTxId: Data? = nil @State private var pendingShieldedRecovery: Transaction? = nil /// Balance whose explainer sheet is up (tap on a breakdown row's body). @State private var balanceInfoNetwork: ChainNetwork? = nil @@ -490,7 +489,7 @@ struct HomeViewContent: View { .sheet(item: $selectedTxDataItem) { item in TransactionDetailsSheet(item: item) } - .sheet(item: $giftCardTxId) { txId in + .sheet(item: $viewModel.giftCardTxId) { txId in GiftCardDetailsSheet(txId: txId) } .sheet(item: $pendingShieldedRecovery) { tx in @@ -852,7 +851,7 @@ struct HomeViewContent: View { #endif } else if GiftCardMetadataProvider.shared.availableMetadata[txItem.txHashData] != nil { // Check if this is a gift card transaction - self.giftCardTxId = txItem.txHashData + viewModel.giftCardTxId = txItem.txHashData } else { self.selectedTxDataItem = txDataItem } @@ -950,6 +949,8 @@ struct GiftCardDetailsSheet: View { cards: viewModel.uiState.cards, isLoadingCardDetails: viewModel.uiState.isLoadingCardDetails, hasBeenPollingForLongTime: viewModel.uiState.hasBeenPollingForLongTime, + loadingError: viewModel.uiState.loadingError, + onRetryLoading: viewModel.uiState.canRetryLoading ? { viewModel.retryLoadingCardDetails() } : nil, onSelectCard: { index in selectedCardIndex = index showBackButton = true diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 0d41dbeef..783adbb65 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -194,6 +194,10 @@ class HomeViewModel: ObservableObject { @Published private(set) var headerHeight: CGFloat = kBaseBalanceHeaderHeight // TDOO: move back to HomeView when fully transitioned to SwiftUI @Published private(set) var showReclassifyTransaction: Transaction? = nil @Published var shouldShowShortcutBanner: Bool = false + /// Drives the gift-card details sheet on the home screen — the single source of truth for + /// both entry points: a tap on a gift-card transaction row, and a completed DashSpend + /// purchase routed here by `HomeViewController.showGiftCardDetails(txId:)`. A local + /// `@State` copy in the view would leave the controller's setter observed by nobody. @Published var giftCardTxId: Data? = nil #if DASHPAY diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index ea3b5857d..fad81df69 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2851,9 +2851,18 @@ /* CoinJoin */ "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; +/* DashSpend */ +"Could not load your gift card" = "Could not load your gift card"; + /* CoinJoin */ "Your mixed coins were moved to your Dash Wallet balance." = "Your mixed coins were moved to your Dash Wallet balance."; +/* DashSpend */ +"We could not confirm this purchase with CTX. If the payment went through, the gift card will appear in your transaction list shortly — please check there before buying again." = "We could not confirm this purchase with CTX. If the payment went through, the gift card will appear in your transaction list shortly — please check there before buying again."; + +/* DashSpend */ +"Your payment was sent, but the network has not confirmed it yet. Your gift card will appear as soon as it is confirmed." = "Your payment was sent, but the network has not confirmed it yet. Your gift card will appear as soon as it is confirmed."; + /* No comment provided by engineer. */ "Moved from" = "Moved from";