From a590070e3493bf940a44e31d0dcb48baeaa40178 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:11:42 +0300 Subject: [PATCH 1/6] fix(dashspend): keep a paid gift-card order the network never confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer paid for two gift cards, was told "Purchase Failed", and received the cards by email while the app kept no trace of them. Four defects on that path, all reproduced on testnet: The SDK reports "unknown" when its acceptance detector sees no relay-back inside 30 s, and `requireAccepted` turned that into a throw indistinguishable from a rejection. The order dies with it: `purchaseGiftCardAndPay` persists nothing until `payWithDashUrl` returns, so no `gift_cards` row, no tx metadata, no icon — while CTX, which acknowledged the signed bytes one step earlier, broadcasts them itself and fulfils the order. Carry the tx hash out through `BIP70Error.broadcastOutcomeUnknown` and `DashSpendError.paymentStatusUnknown`, record the purchase on both the confirmed and unconfirmed paths, and route the screen to the card details instead of an error dialog: the details poller is what turns a pending order into a card. `HomeViewController.showGiftCardDetails` set `HomeViewModel.giftCardTxId`, which nothing observed — the sheet was bound to a local `@State` copy. Every post-purchase entry point funnelled into that dead setter, leaving the buyer on the home screen with the transaction row as the only way in. Bind the sheet to the view model, the single source of truth for both entry points. Card polling hammered CTX every 1.5 s for 40 attempts and then rendered a dead "Failed to load barcode"; the purchase-selection sheet had no failure state at all and kept promising a card nothing was fetching. Back off 1.5 s → 30 s, give up after eight consecutive failures, and offer a retry in both sheets. The "still working on it" hint counted failures rather than polls, so it never appeared while CTX healthily answered "not fulfilled yet". Contact Support fell back to a share sheet carrying log files only when no Apple Mail account exists — the common case for a Gmail user — so reports arrived with an empty "To". Lead with a mail item carrying the support address and subject. Co-Authored-By: Claude Opus 5 --- .../UIViewController+DashWallet.swift | 58 ++++++++++++- .../SwiftDashSDKWalletSending.swift | 12 ++- .../Services/DashSpend/DashSpendError.swift | 9 +++ .../Models/PaymentProtocol/BIP70Error.swift | 7 ++ .../Transactions/SendCoinsService.swift | 13 ++- .../Views/DashSpend/DashSpendPayScreen.swift | 9 +++ .../DashSpend/DashSpendPayViewModel.swift | 23 +++++- .../Components/GiftCardDetailsInfoCard.swift | 18 ++++- .../GiftCardPurchaseSelectionSheet.swift | 21 +++++ .../GiftCardDetails/GiftCardDetailsView.swift | 1 + .../GiftCardDetailsViewModel.swift | 81 ++++++++++++++++--- .../Sources/UI/Home/Views/HomeView.swift | 7 +- .../Sources/UI/Home/Views/HomeViewModel.swift | 4 + DashWallet/en.lproj/Localizable.strings | 6 ++ 14 files changed, 244 insertions(+), 25 deletions(-) diff --git a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift index 69a38e4453..e87f4348ad 100644 --- a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift +++ b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift @@ -137,7 +137,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) } @@ -183,3 +197,45 @@ extension UIViewController { present(activityViewController, animated: true, completion: completion) } } + +/// Carries the support destination into the share sheet used when `MFMailComposeViewController` +/// is unavailable. `UIActivityViewController` exposes no recipient API, so the address travels +/// as a `mailto:` URL for mail activities — which read the recipient and subject from it — and +/// as plain text for every other handler, where it 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 + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletSending.swift index 555d38b1c1..994ea77ae9 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 f36239e8d4..d8e8ddd0a9 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift @@ -35,6 +35,10 @@ 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) var errorDescription: String? { switch self { @@ -68,6 +72,11 @@ enum DashSpendError: Error, LocalizedError { return message case .unknown: return NSLocalizedString("An unknown error occurred. Please try again later.", 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 415f0fe3ce..1b1c3aeed2 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift @@ -39,6 +39,12 @@ 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 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 +64,7 @@ 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 .broadcastOutcomeUnknown: return "Your payment was sent, but the network has not confirmed it yet." } } } diff --git a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift index de91263d06..944f3f530f 100644 --- a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift +++ b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift @@ -84,8 +84,17 @@ public final class SendCoinsService: NSObject { 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) + } 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 3c7aa09923..f3ccc420b8 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 ac6c812f1b..e8bd456f34 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift @@ -307,7 +307,17 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand giftCardNote = response.id // CTX uses BIP70 payment request URLs - txidWire = try await sendCoinsService.payWithDashUrl(url: url) + do { + txidWire = try await sendCoinsService.payWithDashUrl(url: url) + } 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 +364,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 c6323f533f..b6d8e904df 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 189af48113..5c00fd27aa 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 fe9a82de7e..3c0e4e08f2 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 45f0368d0d..3f7d7ad7dc 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 e8e99a7bc0..88fbb90aa0 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 @@ -848,7 +847,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 } @@ -946,6 +945,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 e17d32a887..1d1725bc6f 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 15ce96108a..f8a48fd756 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2851,9 +2851,15 @@ /* 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 */ +"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"; From 791fa72edade4c583c1f17439cd0bcdf2bfc86bf Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:12:05 +0300 Subject: [PATCH 2/6] fix(wallet): guard the preview check so release builds compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshEvonodeEpochBlocks` reads `isPreviewMode`, which only exists under `#if DEBUG`, so the dashpay scheme fails to build in Release with "cannot find 'isPreviewMode' in scope". `reloadShortcuts` already wraps the same guard; match it. Release behaviour is unchanged — SwiftUI previews never run there. Co-Authored-By: Claude Opus 5 --- DashWallet/Sources/UI/Home/Views/HomeViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 1d1725bc6f..783adbb650 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -263,7 +263,9 @@ class HomeViewModel: ObservableObject { /// Routine refresh trigger (screen appear): throttled by the monitor. @MainActor func refreshEvonodeEpochBlocks() { + #if DEBUG guard !isPreviewMode else { return } + #endif evonodeEpochBlocksMonitor.refresh() } From d51a329aedc4547407be9cdd15861596306a06e6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:12:40 +0300 Subject: [PATCH 3/6] fix(dashspend): stop making the buyer wait out the acceptance verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gift-card purchase held the buyer on a spinner for ~38 s: the flow awaited the SDK's network-acceptance verdict, capped at 30 s, after CTX had already acknowledged the signed transaction and started fulfilling the order. The confirmation email routinely arrived before the app moved on. Give the BIP70 send path an `awaitAcceptance` flag and clear it for gift cards. The Payment/ACK exchange still happens before anything is broadcast, so the merchant's acknowledgement — the thing that actually commits the spend — is unchanged; only the verdict now resolves in the background. Trade-off: a rejected broadcast is no longer surfaced at purchase time. It lands in the log while the buyer is already on the card screen, and the card row (recorded regardless of the verdict) leaves the order recoverable. Worth it — a rejection is rare, whereas the wait was on every single purchase. Co-Authored-By: Claude Opus 5 --- .../PaymentProtocol/BIP70PaymentService.swift | 34 ++++++++++++++++--- .../Transactions/SendCoinsService.swift | 9 +++-- .../DashSpend/DashSpendPayViewModel.swift | 7 ++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift index 19c6bb45c9..c7a3827726 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) @@ -309,7 +317,24 @@ final class BIP70PaymentService { } // 6. Broadcast. From this point the guard is never reset — see the invariant above. - let txidHexDisplay = try await wallet.broadcast(prepared) + let txidHexDisplay: String + if awaitAcceptance { + 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 +353,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 944f3f530f..3b3093f08a 100644 --- a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift +++ b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift @@ -77,7 +77,11 @@ 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") } @@ -87,7 +91,8 @@ public final class SendCoinsService: NSObject { let result: SendResult do { result = try await service.confirmAndSendHeadless( - from: requestURL, scheme: uri.scheme, network: network, callbackScheme: uri.callbackScheme) + from: requestURL, scheme: uri.scheme, network: network, + callbackScheme: uri.callbackScheme, awaitAcceptance: awaitAcceptance) } 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 diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift index e8bd456f34..39478f5111 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift @@ -306,9 +306,12 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand giftCardNote = response.id - // CTX uses BIP70 payment request URLs + // 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) + txidWire = try await sendCoinsService.payWithDashUrl(url: url, awaitAcceptance: false) } 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 From 953005f3015bfeae77cd977534cf855ffd96b4af Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:44:42 +0300 Subject: [PATCH 4/6] fix(explore-dash): re-download the merchant database when the network changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both networks share a single `explore.db`, and the marker recording which network's data sits in it is only consulted at launch and on the 24 h timer. Switching chains mid-session therefore left the other network's merchants on screen until the next launch — a testnet wallet browsing the mainnet catalogue, which is what made a DashSpend purchase fail against merchants staging CTX has never heard of. Resync on `DWCurrentNetworkDidChange`, and resolve the Storage reference at use time instead of caching it in `init`. The cached reference was the worse half: a mid-session sync downloaded the *previous* network's archive and then stamped the marker with the *current* network, so the mismatch check agreed with itself from then on and never fired again. A sync already running is left alone rather than raced over the same file. Co-Authored-By: Claude Opus 5 --- .../Services/ExploreDatabaseSyncManager.swift | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift b/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift index b55829a0bb..cda283348d 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift @@ -45,9 +45,20 @@ public class ExploreDatabaseSyncManager { static let databaseWillBeUpdatedNotification = NSNotification.Name(rawValue: "databaseWillBeUpdatedNotification") private let storage = Storage.storage() - private let storageRef: StorageReference + + /// Resolved on every use rather than cached: the network can change inside a session, and a + /// stale reference would download the previous network's archive while the bookkeeping below + /// stamps it with the current network — leaving the wrong merchants installed and the marker + /// claiming otherwise, so the mismatch check never fires again. + private var storageRef: StorageReference { + let path = WalletEnvironment.isMainnet + ? "gs://dash-wallet-firebase.appspot.com/explore/explore-v4.db" + : "gs://dash-wallet-firebase.appspot.com/explore/explore-v4-testnet.db" + return storage.reference(forURL: path) + } private var timer: Timer! + private var networkObserver: NSObjectProtocol? private var databaseVersion: Double = 0 private var lastSync: Double = 0 @@ -76,17 +87,6 @@ public class ExploreDatabaseSyncManager { init() { syncState = .inititialing - - // Initialize storageRef with computed database path - let databasePath: String - let isMainnet = WalletEnvironment.isMainnet - if isMainnet { - databasePath = "gs://dash-wallet-firebase.appspot.com/explore/explore-v4.db" - } else { - databasePath = "gs://dash-wallet-firebase.appspot.com/explore/explore-v4-testnet.db" - } - - storageRef = storage.reference(forURL: databasePath) } public func start() { @@ -96,9 +96,24 @@ public class ExploreDatabaseSyncManager { timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { [weak self] _ in self?.syncIfNeeded() } + + // Both networks share one `explore.db`, so a chain switch leaves the other network's + // merchants on screen. Without this the mismatch is only noticed at the next launch (or + // 24 h later), which is how a testnet wallet ended up browsing the mainnet catalogue. + networkObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name.DWCurrentNetworkDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncIfNeeded() + } } private func syncIfNeeded() { + // A network switch can land while the 24 h timer's (or launch's) download is still + // running; a second pass would race it over the same file on disk. + if case .syncing = syncState { return } + syncState = .fetchingInfo storageRef.getMetadata { [weak self] metadata, _ in @@ -148,6 +163,10 @@ public class ExploreDatabaseSyncManager { deinit { timer.invalidate() timer = nil + + if let networkObserver { + NotificationCenter.default.removeObserver(networkObserver) + } } static let share = ExploreDatabaseSyncManager() From 94145731d5dfcd130d8399c0ff5a05e68f36dd68 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:30:39 +0300 Subject: [PATCH 5/6] fix(dashspend): keep the order when the merchant never acknowledges the payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same loss as the unconfirmed-broadcast case, one step earlier. BIP70 posts the signed transaction to CTX and reads its acknowledgement before anything is broadcast; losing the network between the two throws `ackRejected`, which carries no txid, so the purchase is never recorded. CTX — already holding the bytes — fulfils the order and broadcasts them itself, and the payment surfaces in the app as a plain "Sent" transaction with no merchant, no icon and no card, while the card arrives by email. The caveat was documented in `confirmAndSend`; this makes it survivable. Reproduced by hand: buy a card, switch on Airplane Mode while the payment is in flight, switch it off. Before this change the history row had lost every trace of being a gift-card purchase. `BIP70Error.paymentNotAcknowledged` now carries the app-computed tx hash and `DashSpendError.paymentNotAcknowledged` passes it to the view model, which records the purchase before rethrowing. Deliberately still an error rather than the silent hand-off used for an unconfirmed broadcast: there we know the bytes left the device, here we cannot tell whether CTX received them at all. The dialog says so and points at the transaction list rather than promising a card. If CTX never got the payment, the recorded row simply has no transaction behind it and stays invisible. Co-Authored-By: Claude Opus 5 --- .../Explore Dash/Services/DashSpend/DashSpendError.swift | 9 +++++++++ .../Sources/Models/PaymentProtocol/BIP70Error.swift | 6 ++++++ .../Models/PaymentProtocol/BIP70PaymentService.swift | 8 +++++++- .../Sources/Models/Transactions/SendCoinsService.swift | 5 +++++ .../Views/DashSpend/DashSpendPayViewModel.swift | 8 ++++++++ DashWallet/en.lproj/Localizable.strings | 3 +++ 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift b/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift index d8e8ddd0a9..d9a24aa287 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/DashSpend/DashSpendError.swift @@ -39,6 +39,10 @@ enum DashSpendError: Error, LocalizedError { /// 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 { @@ -72,6 +76,11 @@ 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.", diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift index 1b1c3aeed2..865d8db8c5 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift @@ -39,6 +39,11 @@ 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 @@ -64,6 +69,7 @@ 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 c7a3827726..2fa3bfdbac 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift @@ -312,7 +312,13 @@ 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) } } diff --git a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift index 3b3093f08a..cdd5ca97f3 100644 --- a/DashWallet/Sources/Models/Transactions/SendCoinsService.swift +++ b/DashWallet/Sources/Models/Transactions/SendCoinsService.swift @@ -93,6 +93,11 @@ public final class SendCoinsService: NSObject { 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 diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift index 39478f5111..4a75fe24c2 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendPayViewModel.swift @@ -312,6 +312,14 @@ class DashSpendPayViewModel: NSObject, ObservableObject, NetworkReachabilityHand // 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 diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index f8a48fd756..fd7e49d843 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2857,6 +2857,9 @@ /* 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."; From 494d7ef5ef98662d9765588c29f9faa9fe8c30f8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:00:08 +0300 Subject: [PATCH 6/6] fix(explore-dash): address review findings on the sync and share paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Recipient metadata for share extensions.** `UIActivityItemSource` does have a recipient API — `activityViewControllerShareRecipients(_:)` returning `INPerson` — which the previous change claimed did not exist. Handlers that read it now pre-fill the address properly; the `mailto:` URL and the plain-text item stay as fallbacks for those that do not. **One network per sync attempt.** `storageRef` was resolved separately for the metadata request and the download, so a chain switch between the two paired one network's size, checksum and timestamp with the other network's bytes. The network and its reference are now pinned when the attempt starts and carried through both calls, and the version, timestamp and installed-network markers are written under the pinned network rather than whatever is current when the download lands. **Network changes are queued, not dropped.** The guard only rejected `.syncing`, so a second notification during `.fetchingInfo` could start a parallel attempt, while one during `.syncing` was discarded outright — leaving the wrong network's merchants installed until the next launch, the very failure the observer exists to prevent. Requests arriving mid-attempt now set a pending flag that `settle` drains when the attempt finishes, and the observer is registered before the first sync so a switch during startup is queued too. **An unacknowledged payment always waits for the broadcast verdict.** Skipping it is only defensible because the merchant's acknowledgement has 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. Co-Authored-By: Claude Opus 5 --- .../UIViewController+DashWallet.swift | 22 ++- .../Services/ExploreDatabaseSyncManager.swift | 129 ++++++++++++------ .../PaymentProtocol/BIP70PaymentService.swift | 8 +- 3 files changed, 116 insertions(+), 43 deletions(-) diff --git a/DashWallet/Sources/Categories/UIViewController+DashWallet.swift b/DashWallet/Sources/Categories/UIViewController+DashWallet.swift index e87f4348ad..76d1c5a7a6 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 @@ -199,9 +200,10 @@ extension UIViewController { } /// Carries the support destination into the share sheet used when `MFMailComposeViewController` -/// is unavailable. `UIActivityViewController` exposes no recipient API, so the address travels -/// as a `mailto:` URL for mail activities — which read the recipient and subject from it — and -/// as plain text for every other handler, where it stays visible in the composed message. +/// 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 @@ -238,4 +240,18 @@ final class SupportRecipientActivityItem: NSObject, UIActivityItemSource { ) -> 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/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift b/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift index cda283348d..2946c0c948 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/ExploreDatabaseSyncManager.swift @@ -46,12 +46,16 @@ public class ExploreDatabaseSyncManager { private let storage = Storage.storage() - /// Resolved on every use rather than cached: the network can change inside a session, and a + /// The archive for `network`. Never cached: the network can change inside a session, and a /// stale reference would download the previous network's archive while the bookkeeping below /// stamps it with the current network — leaving the wrong merchants installed and the marker /// claiming otherwise, so the mismatch check never fires again. - private var storageRef: StorageReference { - let path = WalletEnvironment.isMainnet + /// + /// Resolved once per sync attempt and then carried through metadata *and* download: resolving + /// it twice would let a switch land between the two, pairing one network's size/checksum with + /// the other network's bytes. + private func storageReference(for network: String) -> StorageReference { + let path = network == mainnetName ? "gs://dash-wallet-firebase.appspot.com/explore/explore-v4.db" : "gs://dash-wallet-firebase.appspot.com/explore/explore-v4-testnet.db" return storage.reference(forURL: path) @@ -59,6 +63,10 @@ public class ExploreDatabaseSyncManager { private var timer: Timer! private var networkObserver: NSObjectProtocol? + /// A network change that arrived while an attempt was in flight. That attempt is pinned to + /// the previous network, so dropping the request would leave its merchants installed until + /// the next launch — the exact failure this observer exists to prevent. + private var pendingResync = false private var databaseVersion: Double = 0 private var lastSync: Double = 0 @@ -66,16 +74,32 @@ public class ExploreDatabaseSyncManager { var syncState: State var lastServerUpdateDate: Date { Date(timeIntervalSince1970: exploreDatabaseLastVersion) } - // Network-specific name for the downloaded archive only — the database itself always unzips to - // the single `explore.db`, so see `installedDatabaseNetwork` for the mainnet/testnet conflict. - private var networkSpecificFileName: String { - let isMainnet = WalletEnvironment.isMainnet - return isMainnet ? "explore-mainnet" : "explore-testnet" + private let mainnetName = "mainnet" + + /// Name of the downloaded archive for `network` — the database itself always unzips to the + /// single `explore.db`, so see `installedDatabaseNetwork` for the mainnet/testnet conflict. + private func archiveFileName(for network: String) -> String { + network == mainnetName ? "explore-mainnet" : "explore-testnet" + } + + private func versionKey(for network: String) -> String { + network == mainnetName ? "kExploreDatabaseLastVersion_Mainnet" : "kExploreDatabaseLastVersion_Testnet" + } + + private func syncTimestampKey(for network: String) -> String { + network == mainnetName + ? "kExploreDatabaseLastSyncTimestampKey_Mainnet" + : "kExploreDatabaseLastSyncTimestampKey_Testnet" + } + + private func installedVersion(for network: String) -> TimeInterval { + let value = UserDefaults.standard.double(forKey: versionKey(for: network)) + return value == 0 ? bundleExploreDatabaseSyncTime : value } // Network the sync bookkeeping expects, from the SDK (DWEnvironment is frozen post-M6). private var currentNetworkName: String { - WalletEnvironment.isMainnet ? "mainnet" : "testnet" + WalletEnvironment.isMainnet ? mainnetName : "testnet" } // Network whose data currently sits in the shared explore.db (see comment on @@ -90,16 +114,10 @@ public class ExploreDatabaseSyncManager { } public func start() { - syncIfNeeded() - - // Try to sync every 24h - timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { [weak self] _ in - self?.syncIfNeeded() - } - // Both networks share one `explore.db`, so a chain switch leaves the other network's // merchants on screen. Without this the mismatch is only noticed at the next launch (or // 24 h later), which is how a testnet wallet ended up browsing the mainnet catalogue. + // Registered before the first sync so a switch during startup is queued, not missed. networkObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name.DWCurrentNetworkDidChange, object: nil, @@ -107,56 +125,83 @@ public class ExploreDatabaseSyncManager { ) { [weak self] _ in self?.syncIfNeeded() } + + syncIfNeeded() + + // Try to sync every 24h + timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { [weak self] _ in + self?.syncIfNeeded() + } + } + + /// End an attempt: publish its outcome and run the sync that was requested while it was busy. + private func settle(_ state: State) { + syncState = state + + guard pendingResync else { return } + pendingResync = false + syncIfNeeded() } private func syncIfNeeded() { - // A network switch can land while the 24 h timer's (or launch's) download is still - // running; a second pass would race it over the same file on disk. - if case .syncing = syncState { return } + // An attempt already owns the file on disk and is pinned to the network it started on. + // Queue the request rather than racing it — `settle` runs it once this one finishes. + switch syncState { + case .fetchingInfo, .syncing: + pendingResync = true + return + default: + break + } + + // Pinned for the whole attempt: metadata, bytes and bookkeeping must all describe the + // same network even if the user switches chains halfway through. + let network = currentNetworkName + let reference = storageReference(for: network) syncState = .fetchingInfo - storageRef.getMetadata { [weak self] metadata, _ in + reference.getMetadata { [weak self] metadata, _ in guard let wSelf = self else { return } guard let metadata else { - wSelf.syncState = .error(Date(), nil) + wSelf.settle(.error(Date(), nil)) return } guard let timestamp = metadata.customMetadata?[timestampKey], let timeIntervalMillesecond = TimeInterval(timestamp) else { - wSelf.syncState = .error(Date(), nil) + wSelf.settle(.error(Date(), nil)) return } let timeInterval = timeIntervalMillesecond/1000 - let installedVersion = wSelf.exploreDatabaseLastVersion + let installedVersion = wSelf.installedVersion(for: network) let localDatabaseExists = wSelf.hasLocalExploreDatabase() // If local DB is missing (e.g. removed due to schema mismatch), force download // regardless of saved version timestamp to avoid falling back to in-memory DB. if !localDatabaseExists { DWLogger.log("ExploreDash: local explore.db missing, forcing cloud database download") - wSelf.downloadDatabase(metadata: metadata) + wSelf.downloadDatabase(metadata: metadata, from: reference, network: network) return } // The file on disk may belong to the other network (the chain was switched since it was // downloaded). Its version is tracked under that network's key, so the check below would // read as up to date and leave us serving the wrong network's merchants. - if wSelf.installedDatabaseNetwork != wSelf.currentNetworkName { - DWLogger.log("ExploreDash: explore.db belongs to \(wSelf.installedDatabaseNetwork ?? "an unknown network"), current network is \(wSelf.currentNetworkName) — forcing download") - wSelf.downloadDatabase(metadata: metadata) + if wSelf.installedDatabaseNetwork != network { + DWLogger.log("ExploreDash: explore.db belongs to \(wSelf.installedDatabaseNetwork ?? "an unknown network"), current network is \(network) — forcing download") + wSelf.downloadDatabase(metadata: metadata, from: reference, network: network) return } guard timeInterval > installedVersion else { - wSelf.syncState = .synced(Date()) + wSelf.settle(.synced(Date())) return } - wSelf.downloadDatabase(metadata: metadata) + wSelf.downloadDatabase(metadata: metadata, from: reference, network: network) } } @@ -173,23 +218,23 @@ public class ExploreDatabaseSyncManager { } extension ExploreDatabaseSyncManager { - private func downloadDatabase(metadata: StorageMetadata) { + private func downloadDatabase(metadata: StorageMetadata, from reference: StorageReference, network: String) { guard let timestamp = metadata.customMetadata?[timestampKey], let checksum = metadata.customMetadata?[checksumKey], let timeIntervalMillesecond = TimeInterval(timestamp) else { - syncState = .error(Date(), nil) + settle(.error(Date(), nil)) return } syncState = .syncing - let urlToSave = getDocumentsDirectory().appendingPathComponent("\(networkSpecificFileName)-\(timestamp).zip") + let urlToSave = getDocumentsDirectory().appendingPathComponent("\(archiveFileName(for: network))-\(timestamp).zip") - storageRef.getData(maxSize: metadata.size) { [weak self] data, error in + reference.getData(maxSize: metadata.size) { [weak self] data, error in let date = Date() let now = date.timeIntervalSince1970 if let e = error { - self?.syncState = .error(date, e) + self?.settle(.error(date, e)) } else { try? data?.write(to: urlToSave) @@ -205,16 +250,22 @@ extension ExploreDatabaseSyncManager { self?.removeDatabaseSidecars() try await self?.unzipFile(at: urlToSave.path, password: checksum) - self?.exploreDatabaseLastSyncTimestamp = now - self?.exploreDatabaseLastVersion = timeIntervalMillesecond / 1000 - self?.installedDatabaseNetwork = self?.currentNetworkName - self?.syncState = .synced(date) + // Stamped with the network this attempt downloaded, never the one the + // user may have switched to meanwhile — otherwise the marker certifies + // the wrong archive and the mismatch check agrees with itself forever. + if let wSelf = self { + UserDefaults.standard.setValue(now, forKey: wSelf.syncTimestampKey(for: network)) + UserDefaults.standard.setValue(timeIntervalMillesecond / 1000, + forKey: wSelf.versionKey(for: network)) + wSelf.installedDatabaseNetwork = network + } + await MainActor.run { [weak self] in self?.settle(.synced(date)) } NotificationCenter.default.post(name: ExploreDatabaseSyncManager.databaseHasBeenUpdatedNotification, object: nil) try? FileManager.default.removeItem(at: URL(fileURLWithPath: urlToSave.path)) } catch { DWLogger.log("ExploreDash: failed to open DB archive: \(String(describing: error))") - self?.syncState = .error(Date(), error) + await MainActor.run { [weak self] in self?.settle(.error(Date(), error)) } } } } diff --git a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift index 2fa3bfdbac..7b8cc5f2d8 100644 --- a/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift +++ b/DashWallet/Sources/Models/PaymentProtocol/BIP70PaymentService.swift @@ -323,8 +323,14 @@ final class BIP70PaymentService { } // 6. Broadcast. From this point the guard is never reset — see the invariant above. + // 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 { + if awaitAcceptance || !acknowledged { txidHexDisplay = try await wallet.broadcast(prepared) } else { // The merchant already acknowledged the signed bytes, so the spend is committed