diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index ed360c514..75cd7fd3d 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) @@ -526,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 { @@ -537,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 { @@ -549,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 @@ -758,3 +789,106 @@ 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 + /// 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() + 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()) + // 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) + } + // 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] + map[row.txid.lowercased()] = PaymentInfo( + amountDuffs: row.amountDuffs, + 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) + } 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..314b348f6 100644 --- a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift +++ b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift @@ -151,7 +151,38 @@ 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. + var dashPayPayment: DashPayPaymentTxLookup.PaymentInfo? { + DashPayPaymentTxLookup.shared.info(forTxidHex: shieldedDisplayTxid) + } + + /// 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 + } + 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 +385,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 +404,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 @@ -451,6 +489,16 @@ 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 β€” 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) + } // 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/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 b3814e0cb..dfd7c5d9b 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.titleName ?? "?", + 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, @@ -575,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, 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";