From 146136c1ab9a13edadce43d14d8c457a749cd7cb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 17 Jul 2026 02:27:04 +0700 Subject: [PATCH 1/3] fix(dashpay): classify DIP-15 contact payments by the payment record, not net change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paying a contact showed up on the home list as "Received +amount": dash-spv's net-change view watches the jointly-derived DIP-15 payment address (so the outgoing payment output reads as wallet-owned) while the spent inputs aren't attributed, flipping an outgoing payment into a phantom incoming one. The contact profile sheet was already right — it reads the Rust payment history (PersistentDashpayPayment), which records the true direction and amount at send/sync time. Give the tx list the same source of truth: a DashPayPaymentTxLookup overlay (txid → direction/amount/counterparty, same shape as ShieldedTxLookup) mirrored from PersistentDashpayPayment, refreshed on every contacts snapshot rebuild and payments projection. Transaction prefers it for direction, on-screen amount, and live fiat. The SPV-side misclassification (and any balance effect of the watched payment address) still deserves an upstream dash-spv/rs-platform-wallet look — this makes every app surface agree with the recorded payment. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKContactsService.swift | 76 +++++++++++++++++++ .../Transactions/Model/Transaction.swift | 38 ++++++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index ed360c514..2b2ca730d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -148,6 +148,10 @@ final class SwiftDashSDKContactsService: ObservableObject { /// call eagerly; also runs automatically on every (debounced) /// SwiftData save. func refresh() { + // Seed / update the txid → DashPay-payment overlay alongside every + // snapshot rebuild (service init covers the launch case, where the + // home tx list renders before the first contacts sync pass). + DashPayPaymentTxLookup.shared.refresh() guard let ownerId = DWCurrentUserIdentityInfo.shared.identityId, let modelContainer = SwiftDashSDKHost.shared.modelContainer else { // No identity (or storage not up yet) — an empty contact @@ -273,6 +277,9 @@ final class SwiftDashSDKContactsService: ObservableObject { } catch { Self.logger.error("👥 CONTACTS :: payments projection failed: \(String(describing: error), privacy: .public)") } + // The projection may have upserted payment rows; keep the tx-list + // classification overlay in step. + DashPayPaymentTxLookup.shared.refresh() } // MARK: - Notifications read-state (bell badge) @@ -758,3 +765,72 @@ final class ContactsNotificationsBridge: NSObject { UInt(SwiftDashSDKContactsService.shared.unreadNotificationCount) } } + +// MARK: - DashPay payment tx lookup + +/// Thread-safe txid → DashPay-payment snapshot, mirrored from +/// `PersistentDashpayPayment` — the same overlay shape as +/// `ShieldedTxLookup`. The home transaction list uses it to classify +/// DIP-15 contact payments: dash-spv's net-change view misreads an +/// outgoing contact payment as an incoming +amount (the jointly-derived +/// payment address is watched by our wallet, while the spent inputs +/// aren't attributed), so the payment record written by the Rust +/// payment history — which knows the true direction and amount — wins. +/// +/// Keys are display-order txid hex, lowercased (the `PersistentDashpayPayment.txid` +/// convention, which matches explorer txids and `Transaction`'s +/// reversed `txHashData`). +final class DashPayPaymentTxLookup { + static let shared = DashPayPaymentTxLookup() + + struct PaymentInfo: Sendable { + let amountDuffs: UInt64 + /// True when the wallet's identity SENT this payment. + let isOutgoing: Bool + let counterpartyIdentityId: Data + } + + private let lock = NSLock() + private var infoByTxid: [String: PaymentInfo] = [:] + + private init() {} + + /// Snapshot entry for a txid (display-order hex), or nil when the tx + /// is not a recorded DashPay payment. Thread-safe; touches no SwiftData. + func info(forTxidHex txidHex: String) -> PaymentInfo? { + let key = txidHex.lowercased() + lock.lock() + defer { lock.unlock() } + return infoByTxid[key] + } + + /// Rebuild the snapshot from the active container's payment rows. + /// Main actor: reads the SwiftData `mainContext`. A nil container + /// clears the snapshot; a transient fetch error keeps the previous one. + @MainActor + func refresh() { + guard let container = SwiftDashSDKHost.shared.modelContainer else { + store([:]) + return + } + do { + let rows = try container.mainContext.fetch(FetchDescriptor()) + var map: [String: PaymentInfo] = [:] + for row in rows where row.amountDuffs > 0 { + map[row.txid.lowercased()] = PaymentInfo( + amountDuffs: row.amountDuffs, + isOutgoing: row.direction == .sent, + counterpartyIdentityId: row.counterpartyIdentityId) + } + store(map) + } catch { + // Keep the previous snapshot on a transient fetch failure. + } + } + + private func store(_ map: [String: PaymentInfo]) { + lock.lock() + infoByTxid = map + lock.unlock() + } +} diff --git a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift index 39927faad..bc1462799 100644 --- a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift +++ b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift @@ -151,7 +151,26 @@ class Transaction: TransactionDataItem, Identifiable { var id: String { Self.displayHex(snapshot.txid) } - var direction: DSTransactionDirection { _direction } + /// Live DashPay payment record for this tx (`PersistentDashpayPayment` + /// via `DashPayPaymentTxLookup`) — the authoritative direction/amount + /// for a DIP-15 contact payment. dash-spv's net-change view misreads an + /// outgoing contact payment as an incoming +amount (the jointly-derived + /// payment address is watched by our wallet while the spent inputs + /// aren't attributed), so the Rust payment history — which records the + /// true direction at send/sync time — wins. + private var dashPayPayment: DashPayPaymentTxLookup.PaymentInfo? { + DashPayPaymentTxLookup.shared.info(forTxidHex: shieldedDisplayTxid) + } + + /// True when this tx is a recorded DashPay contact payment. + var isDashPayPayment: Bool { dashPayPayment != nil } + + var direction: DSTransactionDirection { + if let payment = dashPayPayment { + return payment.isOutgoing ? .sent : .received + } + return _direction + } private lazy var _direction: DSTransactionDirection = { // FFI direction encoding: 0=incoming, 1=outgoing, 2=internal, // 3=coinjoin. Promote outgoing→moved when the wallet's net @@ -354,9 +373,15 @@ class Transaction: TransactionDataItem, Identifiable { /// Prefer the live shielded amount (computed from `ShieldedTxLookup`) over /// the lazily-cached generic `_dashAmount`, so a row that became a shielded /// transfer after its first render still shows the locked amount — matching - /// the now-live `stateTitle` / `isPendingShieldedTransfer`. + /// the now-live `stateTitle` / `isPendingShieldedTransfer`. A DashPay + /// contact payment likewise shows the recorded payment amount (the + /// net-change view reads the wrong number for those — see `dashPayPayment`). var dashAmount: UInt64 { - shieldedTransferAmountDuffs ?? platformFundingAmountDuffs ?? identityFundingAmountDuffs ?? _dashAmount + dashPayPayment?.amountDuffs + ?? shieldedTransferAmountDuffs + ?? platformFundingAmountDuffs + ?? identityFundingAmountDuffs + ?? _dashAmount } var signedDashAmount: Int64 { if dashAmount == UInt64.max { @@ -367,9 +392,10 @@ class Transaction: TransactionDataItem, Identifiable { } var fiatAmount: String { - // The shielded amount is read live (see `dashAmount`), so compute its - // fiat live too; non-shielded rows keep the lazily-cached value. - if shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil { + // The shielded / DashPay-payment amount is read live (see + // `dashAmount`), so compute its fiat live too; other rows keep the + // lazily-cached value. + if dashPayPayment != nil || shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil { return userInfo?.fiatAmountString(from: dashAmount) ?? NSLocalizedString("Not available", comment: "") } return storedFiatAmount From d1dac19c6268a2c04bb12b02e3544ba2bc525575 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 17 Jul 2026 02:33:44 +0700 Subject: [PATCH 2/3] feat(dashpay): name and avatar on contact-payment rows The home list's DashPay payment rows now read "Sent to " / "Received from " and show the counterparty's avatar (profile image, or the contacts screens' deterministic initial placeholder via the shared ContactAvatarView). The lookup joins the counterparty's cached PersistentDashpayContactProfile (displayName + avatarUrl) at refresh time; rows without a cached profile fall back to the generic Sent/Received title and a "?" placeholder. Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftDashSDKContactsService.swift | 17 ++++++++++++++++- .../Models/Transactions/Model/Transaction.swift | 11 ++++++++++- DashWallet/Sources/UI/Home/Views/HomeView.swift | 16 ++++++++++++++-- DashWallet/en.lproj/Localizable.strings | 6 ++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 2b2ca730d..65bc426f6 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -788,6 +788,11 @@ final class DashPayPaymentTxLookup { /// True when the wallet's identity SENT this payment. let isOutgoing: Bool let counterpartyIdentityId: Data + /// Counterparty's DashPay profile display name, when the profile + /// cache has one — drives the "Sent to " row title. + let counterpartyName: String? + /// Counterparty's avatar URL, when their profile carries one. + let counterpartyAvatarURL: String? } private let lock = NSLock() @@ -815,12 +820,22 @@ final class DashPayPaymentTxLookup { } do { let rows = try container.mainContext.fetch(FetchDescriptor()) + // Join the counterparty's cached DashPay profile for the row + // title/avatar. Tiny tables; fetch-all and index in Swift. + let profiles = try container.mainContext.fetch(FetchDescriptor()) + var profileByContactId: [Data: (name: String?, avatarURL: String?)] = [:] + for profile in profiles { + profileByContactId[profile.contactIdentityId] = (profile.displayName, profile.avatarUrl) + } var map: [String: PaymentInfo] = [:] for row in rows where row.amountDuffs > 0 { + let profile = profileByContactId[row.counterpartyIdentityId] map[row.txid.lowercased()] = PaymentInfo( amountDuffs: row.amountDuffs, isOutgoing: row.direction == .sent, - counterpartyIdentityId: row.counterpartyIdentityId) + counterpartyIdentityId: row.counterpartyIdentityId, + counterpartyName: profile?.name?.isEmpty == false ? profile?.name : nil, + counterpartyAvatarURL: profile?.avatarURL?.isEmpty == false ? profile?.avatarURL : nil) } store(map) } catch { diff --git a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift index bc1462799..9b8afce3e 100644 --- a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift +++ b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift @@ -158,7 +158,7 @@ class Transaction: TransactionDataItem, Identifiable { /// payment address is watched by our wallet while the spent inputs /// aren't attributed), so the Rust payment history — which records the /// true direction at send/sync time — wins. - private var dashPayPayment: DashPayPaymentTxLookup.PaymentInfo? { + var dashPayPayment: DashPayPaymentTxLookup.PaymentInfo? { DashPayPaymentTxLookup.shared.info(forTxidHex: shieldedDisplayTxid) } @@ -477,6 +477,15 @@ class Transaction: TransactionDataItem, Identifiable { return NSLocalizedString("Identity top-up", comment: "Asset lock topping up a DashPay identity's credits") } } + // A DashPay contact payment names the counterparty when their + // profile is cached; otherwise it falls through to the generic + // Sent/Received (the overlaid `direction` already points the + // right way). + if let payment = dashPayPayment, let name = payment.counterpartyName { + return payment.isOutgoing + ? String(format: NSLocalizedString("Sent to %@", comment: "DashPay payment row — recipient's display name"), name) + : String(format: NSLocalizedString("Received from %@", comment: "DashPay payment row — sender's display name"), name) + } // Every balance-to-balance transfer reads "Internal Transfer"; the // route is carried visually (source icon → destination badge), by // the home row's route pill, and by the detail sheet's From/To rows. diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index b3814e0cb..a8428b951 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -557,14 +557,26 @@ struct HomeViewContent: View { // Service/merchant metadata wins over the route pair — such rows // are external payments, not transfers of own funds. let routeSymbols = metadata == nil ? transferRouteSymbols(txItem: txItem) : nil + // A DashPay contact payment shows the counterparty's avatar + // (profile image, or the deterministic initial placeholder the + // contacts screens use). + let contactAvatar: AnyView? = metadata == nil + ? txItem.dashPayPayment.map { payment in + AnyView(ContactAvatarView( + title: payment.counterpartyName ?? "?", + avatarURL: payment.counterpartyAvatarURL, + identitySeed: payment.counterpartyIdentityId, + size: 30)) + } + : nil DashUIKit.TransactionView( // A merchant logo is a bitmap and goes through `iconView` so it can be clipped to a // circle; every other row resolves to a named icon. - icon: metadata?.icon == nil && routeSymbols == nil ? icons.primary.dashIconSource : nil, + icon: metadata?.icon == nil && routeSymbols == nil && contactAvatar == nil ? icons.primary.dashIconSource : nil, iconView: metadata?.icon.map { AnyView(Image(uiImage: $0).resizable().scaledToFit().clipShape(Circle())) - } ?? routeSymbols.map { transferRouteIcon($0.source) }, + } ?? contactAvatar ?? routeSymbols.map { transferRouteIcon($0.source) }, secondaryIcon: routeSymbols.map { DashIconSource.system($0.destination) } ?? icons.secondary?.dashIconSource, title: metadata?.title ?? txItem.stateTitle, diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index d85439285..fd7d42c63 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2817,6 +2817,9 @@ /* No comment provided by engineer. */ "Received from" = "Received from"; +/* DashPay payment row — sender's display name */ +"Received from %@" = "Received from %@"; + /* CrowdNode */ "Receiving rewards" = "Receiving rewards"; @@ -3203,6 +3206,9 @@ /* No comment provided by engineer. */ "Sent to" = "Sent to"; +/* DashPay payment row — recipient's display name */ +"Sent to %@" = "Sent to %@"; + /* DashPay Contacts */ "Sent you a request" = "Sent you a request"; From ba5ad6081c571b6e5e8fd3936bef47898d6de237 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 17 Jul 2026 02:42:47 +0700 Subject: [PATCH 3/3] fix(dashpay): make contact alias saves work; alias-first payment row titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a contact's alias (or note / hidden) died with the bogus "Contact request not found": mutateEstablishedContact resolves the live EstablishedContact from the SDK's per-session in-memory state, while the contact the sheet renders comes from SwiftData — the same trap acceptContactRequest had. Same cure: on a miss, run a DashPay sync pass and retry the lookup once (the setters and the profile sheet's save paths go async for it). The payment rows then honor the alias: title reads "Sent to " (alias joined from the contact-request rows, winning over the profile display name — matching ContactItem.displayTitle), with the contact's actual name underneath on the gray details line, and the avatar placeholder keyed by the same title. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKContactsService.swift | 63 ++++++++++++++++--- .../Transactions/Model/Transaction.swift | 23 +++++-- .../SwiftUI/ContactProfileSheet.swift | 26 ++++---- .../Sources/UI/Home/Views/HomeView.swift | 6 +- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 65bc426f6..75cd7fd3d 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -533,8 +533,8 @@ final class SwiftDashSDKContactsService: ObservableObject { /// Set / clear the owner-private alias on an established contact. /// Local-only state (no on-chain artifact, no auth gate); durably /// persisted via `flushPersist`. - func setAlias(_ alias: String?, for contactId: Data) throws { - try mutateEstablishedContact(contactId) { contact in + func setAlias(_ alias: String?, for contactId: Data) async throws { + try await mutateEstablishedContact(contactId) { contact in if let alias, !alias.isEmpty { try contact.setAlias(alias) } else { @@ -544,8 +544,8 @@ final class SwiftDashSDKContactsService: ObservableObject { } /// Set / clear the owner-private note on an established contact. - func setNote(_ note: String?, for contactId: Data) throws { - try mutateEstablishedContact(contactId) { contact in + func setNote(_ note: String?, for contactId: Data) async throws { + try await mutateEstablishedContact(contactId) { contact in if let note, !note.isEmpty { try contact.setNote(note) } else { @@ -556,21 +556,45 @@ final class SwiftDashSDKContactsService: ObservableObject { /// Hide / unhide an established contact (moves it to the Hidden /// section of the contacts list). - func setHidden(_ hidden: Bool, for contactId: Data) throws { - try mutateEstablishedContact(contactId) { contact in + func setHidden(_ hidden: Bool, for contactId: Data) async throws { + try await mutateEstablishedContact(contactId) { contact in hidden ? try contact.hide() : try contact.unhide() } } + /// One in-memory lookup of an established contact — split out so the + /// mutation path can retry it after a resync. + private func establishedContactHandle( + wallet: ManagedPlatformWallet, + ownerId: Data, + contactId: Data + ) throws -> EstablishedContact? { + let identity = try wallet.managedIdentity(identityId: ownerId) + return try identity.getEstablishedContact(contactId: contactId) + } + private func mutateEstablishedContact( _ contactId: Data, _ mutate: (EstablishedContact) throws -> Void - ) throws { + ) async throws { let (wallet, _, ownerId) = try requireContext() do { - let identity = try wallet.managedIdentity(identityId: ownerId) - guard let contact = try identity.getEstablishedContact(contactId: contactId) else { - throw ServiceError.requestNotFound + // The SDK's in-memory contact state is PER-SESSION while the + // rows the UI acted on persist across sessions — same trap as + // `acceptContactRequest`. A miss doesn't mean the contact is + // gone: resync and look again before failing (setting an alias + // right after launch used to die here with the bogus + // "Contact request not found"). + let contact: EstablishedContact + if let live = try establishedContactHandle(wallet: wallet, ownerId: ownerId, contactId: contactId) { + contact = live + } else { + Self.logger.info("👥 CONTACTS :: established contact not in memory — resyncing before mutation") + await syncNow() + guard let retried = try establishedContactHandle(wallet: wallet, ownerId: ownerId, contactId: contactId) else { + throw ServiceError.requestNotFound + } + contact = retried } try mutate(contact) // Durability: the setters mutate in-memory Rust state; the @@ -791,8 +815,16 @@ final class DashPayPaymentTxLookup { /// Counterparty's DashPay profile display name, when the profile /// cache has one — drives the "Sent to " row title. let counterpartyName: String? + /// Owner-set alias for the counterparty, when one exists — wins + /// over `counterpartyName` in the row title (the profile name then + /// moves to the gray details line). + let counterpartyAlias: String? /// Counterparty's avatar URL, when their profile carries one. let counterpartyAvatarURL: String? + + /// Row-title name: alias first (owner's own label for the contact), + /// then the profile display name. Matches `ContactItem.displayTitle`. + var titleName: String? { counterpartyAlias ?? counterpartyName } } private let lock = NSLock() @@ -827,6 +859,16 @@ final class DashPayPaymentTxLookup { for profile in profiles { profileByContactId[profile.contactIdentityId] = (profile.displayName, profile.avatarUrl) } + // Owner-set aliases ride on the contact-request rows (either + // direction of the pair may carry it — same read the contacts + // snapshot uses). + let requests = try container.mainContext.fetch(FetchDescriptor()) + var aliasByContactId: [Data: String] = [:] + for request in requests { + if let alias = request.contactAlias, !alias.isEmpty { + aliasByContactId[request.contactIdentityId] = alias + } + } var map: [String: PaymentInfo] = [:] for row in rows where row.amountDuffs > 0 { let profile = profileByContactId[row.counterpartyIdentityId] @@ -835,6 +877,7 @@ final class DashPayPaymentTxLookup { isOutgoing: row.direction == .sent, counterpartyIdentityId: row.counterpartyIdentityId, counterpartyName: profile?.name?.isEmpty == false ? profile?.name : nil, + counterpartyAlias: aliasByContactId[row.counterpartyIdentityId], counterpartyAvatarURL: profile?.avatarURL?.isEmpty == false ? profile?.avatarURL : nil) } store(map) diff --git a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift index 9b8afce3e..314b348f6 100644 --- a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift +++ b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift @@ -165,6 +165,18 @@ class Transaction: TransactionDataItem, Identifiable { /// True when this tx is a recorded DashPay contact payment. var isDashPayPayment: Bool { dashPayPayment != nil } + /// The counterparty's profile name for the row's gray details line — + /// only when the title used the owner-set alias, so both render + /// (alias on top, their actual name underneath). `nil` when the title + /// already shows the profile name (or nothing is cached). + var dashPayPaymentDetailsName: String? { + guard let payment = dashPayPayment, + payment.counterpartyAlias?.isEmpty == false, + let name = payment.counterpartyName, + name != payment.counterpartyAlias else { return nil } + return name + } + var direction: DSTransactionDirection { if let payment = dashPayPayment { return payment.isOutgoing ? .sent : .received @@ -477,11 +489,12 @@ class Transaction: TransactionDataItem, Identifiable { return NSLocalizedString("Identity top-up", comment: "Asset lock topping up a DashPay identity's credits") } } - // A DashPay contact payment names the counterparty when their - // profile is cached; otherwise it falls through to the generic - // Sent/Received (the overlaid `direction` already points the - // right way). - if let payment = dashPayPayment, let name = payment.counterpartyName { + // A DashPay contact payment names the counterparty — the owner-set + // alias when one exists, else their profile display name (their + // name then moves to the gray details line). No cached + // name at all falls through to the generic Sent/Received (the + // overlaid `direction` already points the right way). + if let payment = dashPayPayment, let name = payment.titleName { return payment.isOutgoing ? String(format: NSLocalizedString("Sent to %@", comment: "DashPay payment row — recipient's display name"), name) : String(format: NSLocalizedString("Received from %@", comment: "DashPay payment row — sender's display name"), name) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift index 27bad801f..255e1b741 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift @@ -335,25 +335,27 @@ struct ContactProfileSheet: View { } private func saveMeta() { - do { - try service.setAlias(aliasText.trimmingCharacters(in: .whitespaces), for: contact.contactIdentityId) - try service.setNote(noteText.trimmingCharacters(in: .whitespaces), for: contact.contactIdentityId) - withAnimation { metaSavedToast = true } - Task { + Task { @MainActor in + do { + try await service.setAlias(aliasText.trimmingCharacters(in: .whitespaces), for: contact.contactIdentityId) + try await service.setNote(noteText.trimmingCharacters(in: .whitespaces), for: contact.contactIdentityId) + withAnimation { metaSavedToast = true } try? await Task.sleep(nanoseconds: 1_200_000_000) withAnimation { metaSavedToast = false } + } catch { + errorMessage = error.localizedDescription } - } catch { - errorMessage = error.localizedDescription } } private func toggleHidden() { - do { - try service.setHidden(!isHidden, for: contact.contactIdentityId) - isHidden.toggle() - } catch { - errorMessage = error.localizedDescription + Task { @MainActor in + do { + try await service.setHidden(!isHidden, for: contact.contactIdentityId) + isHidden.toggle() + } catch { + errorMessage = error.localizedDescription + } } } diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index a8428b951..dfd7c5d9b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -563,7 +563,7 @@ struct HomeViewContent: View { let contactAvatar: AnyView? = metadata == nil ? txItem.dashPayPayment.map { payment in AnyView(ContactAvatarView( - title: payment.counterpartyName ?? "?", + title: payment.titleName ?? "?", avatarURL: payment.counterpartyAvatarURL, identitySeed: payment.counterpartyIdentityId, size: 30)) @@ -587,7 +587,9 @@ struct HomeViewContent: View { ? NSLocalizedString("Pending", comment: "") : (metadata?.details?.isEmpty == false ? metadata?.details - : transferRouteDetails(txItem: txItem)), + // Alias-titled contact payments show the + // counterparty's actual name underneath. + : transferRouteDetails(txItem: txItem) ?? txItem.dashPayPaymentDetailsName), dashAmount: txItem.signedDashAmount, amountSign: .always, fiat: txItem.fiatAmount,