Skip to content
58 changes: 57 additions & 1 deletion DashWallet/Sources/Categories/UIViewController+DashWallet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func activityViewController(
_ activityViewController: UIActivityViewController,
subjectForActivityType activityType: UIActivity.ActivityType?
) -> String {
subject
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private var timer: Timer!
private var networkObserver: NSObjectProtocol?

private var databaseVersion: Double = 0
private var lastSync: Double = 0
Expand Down Expand Up @@ -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() {
Expand All @@ -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 }

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
syncState = .fetchingInfo

storageRef.getMetadata { [weak self] metadata, _ in
Expand Down Expand Up @@ -148,6 +163,10 @@ public class ExploreDatabaseSyncManager {
deinit {
timer.invalidate()
timer = nil

if let networkObserver {
NotificationCenter.default.removeObserver(networkObserver)
}
}

static let share = ExploreDatabaseSyncManager()
Expand Down
7 changes: 7 additions & 0 deletions DashWallet/Sources/Models/PaymentProtocol/BIP70Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)")
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let callbackURL = Self.makeCallbackURL(scheme: confirmation.callbackScheme,
address: confirmation.primaryAddress,
Expand All @@ -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
Expand Down
20 changes: 17 additions & 3 deletions DashWallet/Sources/Models/Transactions/SendCoinsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,29 @@ 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.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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,21 @@ 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.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:
Expand Down Expand Up @@ -354,11 +367,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 {
Expand Down
Loading
Loading