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 @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 <name>" 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<PersistentDashpayPayment>())
// 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<PersistentDashpayContactProfile>())
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<PersistentDashpayContactRequest>())
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()
}
}
60 changes: 54 additions & 6 deletions DashWallet/Sources/Models/Transactions/Model/Transaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
20 changes: 17 additions & 3 deletions DashWallet/Sources/UI/Home/Views/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -557,14 +557,26 @@ struct HomeViewContent<Content: View>: 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,
Expand All @@ -575,7 +587,9 @@ struct HomeViewContent<Content: View>: 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,
Expand Down
6 changes: 6 additions & 0 deletions DashWallet/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";

Expand Down
Loading