Skip to content
74 changes: 73 additions & 1 deletion DashWallet/Sources/Categories/UIViewController+DashWallet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// limitations under the License.
//

import Intents
import UIKit
import MessageUI
import SwiftDashSDK
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

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

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
Loading
Loading