Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
44 changes: 39 additions & 5 deletions DashWallet/Sources/Models/Transactions/WalletSendService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
/// <id> — 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
Loading