diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift index 38346fcb9..c23b4cac5 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/ContactItem.swift @@ -68,6 +68,21 @@ struct ContactItem: Identifiable, Equatable { /// Hidden contacts move to the collapsed Hidden section. let isHidden: Bool + /// The SDK gave up on building this contact's DIP-15 payment + /// channel for a permanent reason (`EstablishedContact + /// .payment_channel_broken`): the counterparty's encrypted xpub + /// cannot be decrypted, or their key shape can never satisfy the + /// ECDH gate. + /// + /// Being `.established` is NOT the same as being payable — + /// friendship is mutual contact requests, while paying needs the + /// external contact account those requests are the input to. A + /// broken channel is unrecoverable from this side: the sweep never + /// retries it, and only a fresh contact request from the CONTACT + /// clears the flag. Surfacing it is what stops the UI offering a + /// Pay button that can only ever fail. + let paymentChannelBroken: Bool + /// `dashpay.profile.avatarUrl` from the synced contact-profile cache. let avatarURL: String? diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 73ef52a38..a3a119a70 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -220,6 +220,10 @@ final class SwiftDashSDKContactsService: ObservableObject { alias: rows.compactMap(\.contactAlias).first(where: { !$0.isEmpty }), note: rows.compactMap(\.contactNote).first(where: { !$0.isEmpty }), isHidden: rows.contains(where: \.contactHidden), + // The persister mirrors the flag onto both direction + // rows, so either carrying it means the channel is + // broken. + paymentChannelBroken: rows.contains(where: \.paymentChannelBroken), avatarURL: profile?.avatarUrl, publicMessage: profile?.publicMessage, createdAt: Date(timeIntervalSince1970: TimeInterval(newestMillis) / 1000), diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index d1d5b0cbe..e3ee61419 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -453,11 +453,16 @@ final class WalletSendService: NSObject { ) } - let (txid, feeDuffs) = try await context.wallet.sendDashPayPayment( - fromIdentityId: context.ourId, - toContactIdentityId: contactIdentityId, - amountDuffs: amount, - memo: memo) + let (txid, feeDuffs): (Data, UInt64) + do { + (txid, feeDuffs) = try await context.wallet.sendDashPayPayment( + fromIdentityId: context.ourId, + toContactIdentityId: contactIdentityId, + amountDuffs: amount, + memo: memo) + } catch { + throw Self.contactPaymentError(from: error) + } Self.logger.info("💸 TXSEND :: pay-to-contact broadcast, txid \(txid.map { String(format: "%02x", $0) }.joined(), privacy: .public), fee \(feeDuffs, privacy: .public) duffs") return (txid: txid, feeDuffs: feeDuffs) } @@ -654,6 +659,35 @@ private extension WalletSendService { static let errorDomain = "org.dashfoundation.dash.wallet-send-service" + /// Translate the SDK's internal missing-external-account diagnostic into + /// something a user can act on. + /// + /// A contact's DIP-15 external account is built in the background from the + /// counterparty's contact request; until it exists the SDK fails the send + /// with "Invalid identity data: No DashpayExternalAccount found for contact + /// — call register_external_contact_account first". That reached users + /// verbatim in an alert: it names an API only the SDK can call, so it reads + /// as an instruction for something they cannot do. + /// + /// Mapped here rather than at the presentation layer so the diagnostic stops + /// at the boundary that owns the SDK call, and every present and future + /// caller of `sendToContact` gets the same treatment. Deliberately narrow — + /// anything else is returned untouched rather than hidden behind a generic + /// message. + static func contactPaymentError(from error: Error) -> Error { + let description = error.localizedDescription + guard description.contains("DashpayExternalAccount") + || description.contains("register_external_contact_account") + else { + return error + } + return makeError( + code: .dashPayPaymentUnavailable, + description: NSLocalizedString( + "This contact's payment channel isn't ready yet. It's still being set up in the background — please try again in a few minutes.", + comment: "DashPay Contacts")) + } + static func makeError(code: ErrorCode, description: String) -> NSError { NSError( domain: errorDomain, diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift index 18ce4b6af..466a4f05c 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift @@ -254,23 +254,33 @@ struct ContactProfileSheet: View { .zIndex(-1) } - // Android Button.Primary.Blue: full-width filled pay CTA. - Button { - showingPaySheet = true - } label: { - Label( - NSLocalizedString("Pay", comment: "DashPay Contacts"), - systemImage: "arrow.up.circle.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - .frame(maxWidth: .infinity) - .frame(height: 46) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.dash.blue)) + // Being established means the contact requests are + // mutual; it does NOT mean we can pay. Paying needs the + // DIP-15 external account built from the counterparty's + // encrypted xpub, and when the SDK has permanently + // failed to build it, every Pay tap ends in a failure + // the user can do nothing about. Say so instead. + if contact.paymentChannelBroken { + paymentChannelBrokenNotice + } else { + // Android Button.Primary.Blue: full-width filled pay CTA. + Button { + showingPaySheet = true + } label: { + Label( + NSLocalizedString("Pay", comment: "DashPay Contacts"), + systemImage: "arrow.up.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.blue)) + } + .buttonStyle(.plain) + .padding(.horizontal, 32) } - .buttonStyle(.plain) - .padding(.horizontal, 32) paymentsSection @@ -400,6 +410,37 @@ struct ContactProfileSheet: View { return error.localizedDescription } + /// Shown in place of the Pay CTA when the contact's payment + /// channel is permanently broken. + /// + /// Deliberately actionable rather than an error: the flag only + /// clears when the CONTACT sends a fresh request, so telling the + /// user what to ask for is the only thing that can fix it. A + /// disabled-looking button with no explanation would leave them + /// tapping and reading a Rust diagnostic instead. + private var paymentChannelBrokenNotice: some View { + VStack(spacing: 6) { + Label( + NSLocalizedString("Payments unavailable", comment: "DashPay Contacts"), + systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dashGolden) + Text(NSLocalizedString( + "This contact's payment details couldn't be set up. Ask them to send you a new contact request.", + comment: "DashPay Contacts")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .padding(.horizontal, 32) + } + // MARK: Payments between us — history card private var paymentsSection: some View {