From 2d12e2d55a755c029d51175d96cf32321c30fd51 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 22:46:11 +0700 Subject: [PATCH 01/15] feat(identities): browsable public keys on the identity detail page The identity detail's "Public keys" row was a bare count. It now pushes a Public Keys page listing every persisted DPP key: key id, purpose, security level, key type, read-only/disabled badges, the copyable key material, contract bounds, and the DIP-9 derivation path when known. Keys project into immutable row models at reload time (same no-live-@Model contract as the identity rows). Co-Authored-By: Claude Fable 5 --- .../Security/Wallets/IdentitiesScreen.swift | 205 +++++++++++++++++- .../Wallets/IdentitiesViewModel.swift | 51 ++++- DashWallet/en.lproj/Localizable.strings | 27 +++ 3 files changed, 278 insertions(+), 5 deletions(-) diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift index c526f1da6..12978f429 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift @@ -540,14 +540,39 @@ struct IdentityDetailScreen: View { label: NSLocalizedString("Identity index", comment: "Identities"), value: "#\(row.identityIndex)") divider - detailRow( - label: NSLocalizedString("Public keys", comment: "Identities"), - value: "\(row.publicKeyCount)") + Button(action: showPublicKeys) { + HStack { + Text(NSLocalizedString("Public keys", comment: "Identities")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text("\(row.publicKeys.count)") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(row.publicKeys.isEmpty) } .background(Color.dash.secondaryBackground) .cornerRadius(12) } + /// Push the identity's public-keys page (same push pattern as the + /// list → detail navigation). + private func showPublicKeys() { + let controller = UIHostingController( + rootView: IdentityPublicKeysScreen(row: row, vc: vc)) + controller.hidesBottomBarWhenPushed = true + vc.pushViewController(controller, animated: true) + } + private var namesCard: some View { VStack(alignment: .leading, spacing: 8) { Text(NSLocalizedString("Usernames", comment: "Identities")) @@ -636,3 +661,177 @@ struct IdentityDetailScreen: View { } } } + +// MARK: - IdentityPublicKeysScreen + +/// The identity's DPP public keys (pushed from the detail's "Public keys" +/// row): one card per key with its id, purpose, security level, key type, +/// state badges, and the copyable key material. Read-only — key management +/// happens on Platform, not here. +struct IdentityPublicKeysScreen: View { + let row: IdentityRowModel + let vc: UINavigationController + + /// Key id whose Copy button just fired (transient checkmark). + @State private var copiedKeyId: Int32? + + var body: some View { + ZStack { + Color.dash.primaryBackground.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + header + + ScrollView { + VStack(spacing: 12) { + ForEach(row.publicKeys) { key in + keyCard(key) + } + } + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 24) + } + } + } + .navigationBarHidden(true) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Button(action: { vc.popViewController(animated: true) }) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color.dash.primaryText) + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) + } + Spacer() + } + .padding(.horizontal, 5) + .padding(.top, 10) + + Text(NSLocalizedString("Public keys", comment: "Identities")) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.dash.primaryText) + .padding(.horizontal, 20) + .padding(.top, 20) + + Text(row.title) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .padding(.horizontal, 20) + .padding(.top, 2) + .padding(.bottom, 12) + } + } + + private func keyCard(_ key: IdentityKeyRowModel) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(String.localizedStringWithFormat( + NSLocalizedString("Key #%d", comment: "Identities: one identity public key, by its DPP key id"), + key.keyId)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Spacer() + if key.isDisabled { + IdentityBadge( + text: NSLocalizedString("Disabled", comment: "Identities: this identity key was disabled on Platform"), + icon: "nosign", + color: .red) + } else if key.readOnly { + IdentityBadge( + text: NSLocalizedString("Read-only", comment: "Identities: key visible to the wallet but not usable for signing"), + icon: "eye", + color: .orange) + } + } + + VStack(spacing: 0) { + attributeRow( + label: NSLocalizedString("Purpose", comment: "Identities: what a public key is used for"), + value: key.purposeText) + attributeRow( + label: NSLocalizedString("Security level", comment: "Identities: DPP security level of a public key"), + value: key.securityLevelText) + attributeRow( + label: NSLocalizedString("Type", comment: "Identities"), + value: key.keyTypeText) + } + + Text(NSLocalizedString("Key data", comment: "Identities: the public key bytes")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + Text(key.publicKeyHex) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .textSelection(.enabled) + + Button(action: { copy(key) }) { + HStack(spacing: 6) { + Image(systemName: copiedKeyId == key.keyId ? "checkmark" : "doc.on.doc") + Text(copiedKeyId == key.keyId + ? NSLocalizedString("Copied", comment: "") + : NSLocalizedString("Copy", comment: "")) + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.dash.blue) + } + + if !key.contractBoundIds.isEmpty { + Text(key.contractBoundDocumentType == nil + ? NSLocalizedString("Bound to contract", comment: "Identities: this key only signs for one data contract") + : String.localizedStringWithFormat( + NSLocalizedString("Bound to contract, document type “%@”", comment: "Identities: this key only signs one document type of one data contract"), + key.contractBoundDocumentType ?? "")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + ForEach(key.contractBoundIds, id: \.self) { contractId in + Text(contractId) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.dash.secondaryText) + .textSelection(.enabled) + } + } + + if let path = key.derivationPath { + Text(NSLocalizedString("Derivation path", comment: "Identities: BIP-32 style derivation path of this key")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + Text(path) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.dash.secondaryText) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + private func attributeRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(value) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 6) + } + + private func copy(_ key: IdentityKeyRowModel) { + UIPasteboard.general.string = key.publicKeyHex + copiedKeyId = key.keyId + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if copiedKeyId == key.keyId { copiedKeyId = nil } + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift index 580eebf42..764522bf3 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift @@ -28,6 +28,32 @@ import Foundation import SwiftData import SwiftDashSDK +/// Immutable projection of one `PersistentPublicKey`, captured at reload +/// time (same no-live-`@Model` contract as `IdentityRowModel`). Feeds the +/// identity detail's Public Keys page. +struct IdentityKeyRowModel: Identifiable { + let keyId: Int32 + /// Human name of the DPP purpose/level/type, or the raw stored value + /// when it doesn't map to a known enum case (future variants render + /// honestly instead of being hidden). + let purposeText: String + let securityLevelText: String + let keyTypeText: String + let readOnly: Bool + let isDisabled: Bool + /// The key material as stored (33-byte compressed point, 48-byte BLS + /// pubkey, or a 20-byte hash for the *Hash160 types). + let publicKeyHex: String + /// Base58 contract ids this key is bound to; empty when unbounded. + let contractBoundIds: [String] + /// Document-type name for a `.singleContractDocumentType` bound. + let contractBoundDocumentType: String? + /// DIP-9 derivation path breadcrumb, when persisted. + let derivationPath: String? + + var id: Int32 { keyId } +} + /// Immutable per-row projection of a `PersistentIdentity`, captured at /// reload time so the view never holds live `@Model` references. struct IdentityRowModel: Identifiable { @@ -54,7 +80,9 @@ struct IdentityRowModel: Identifiable { /// active wallet, so the detail page needs this to offer it. let walletId: Data? let identityIndex: UInt32 - let publicKeyCount: Int + /// The identity's public keys, ordered by key id (detail page list; + /// its count drives the summary row). + let publicKeys: [IdentityKeyRowModel] /// Every DPNS label owned by this identity (detail sheet list). let dpnsNames: [String] /// Contested label submitted by this wallet but not owned yet. @@ -273,7 +301,9 @@ final class IdentitiesViewModel: ObservableObject { walletName: identity.wallet?.label.nonEmptyString, walletId: identity.wallet?.walletId, identityIndex: identity.identityIndex, - publicKeyCount: identity.publicKeys.count, + publicKeys: identity.publicKeys + .sorted { $0.keyId < $1.keyId } + .map(keyRowModel(for:)), dpnsNames: ownedNames, pendingContestedName: pendingBelongsToIdentity ? pendingLabel : nil, pendingVotingEndTime: pendingBelongsToIdentity @@ -282,6 +312,23 @@ final class IdentitiesViewModel: ObservableObject { isMainIdentity: mainIdentityId != nil && identity.identityId == mainIdentityId) } + /// Project one persisted identity key for display. The stored + /// purpose/level/type are raw-value strings; unmapped values (future + /// DPP variants) render as the raw value rather than being hidden. + private static func keyRowModel(for key: PersistentPublicKey) -> IdentityKeyRowModel { + IdentityKeyRowModel( + keyId: key.keyId, + purposeText: key.purposeEnum?.description ?? key.purpose, + securityLevelText: key.securityLevelEnum?.description ?? key.securityLevel, + keyTypeText: key.keyTypeEnum?.name ?? key.keyType, + readOnly: key.readOnly, + isDisabled: key.isDisabled, + publicKeyHex: key.publicKeyData.map { String(format: "%02x", $0) }.joined(), + contractBoundIds: (key.contractBounds ?? []).map { $0.toBase58String() }, + contractBoundDocumentType: key.contractBoundsDocumentTypeName, + derivationPath: key.identityDerivationPath) + } + private static func uint64(from value: Any?) -> UInt64? { if let number = value as? NSNumber { return number.uint64Value diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 9a05170dc..bd6fb798f 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -502,6 +502,12 @@ /* Voting */ "Blocked '%@' username" = "Blocked '%@' username"; +/* Identities: this key only signs for one data contract */ +"Bound to contract" = "Bound to contract"; + +/* Identities: this key only signs one document type of one data contract */ +"Bound to contract, document type “%@”" = "Bound to contract, document type “%@”"; + /* No comment provided by engineer. */ "Broadcasting" = "Broadcasting"; @@ -1031,6 +1037,9 @@ /* CrowdNode */ "Deposit sent" = "Deposit sent"; +/* Identities: BIP-32 style derivation path of this key */ +"Derivation path" = "Derivation path"; + /* No comment provided by engineer. */ "Destination" = "Destination"; @@ -1043,6 +1052,9 @@ /* No comment provided by engineer. */ "DEVICE SECURITY COMPROMISED\nAny 'jailbreak' app can access any other app's keychain data (and steal your Dash). Wipe this wallet immediately and restore on a secure device." = "DEVICE SECURITY COMPROMISED\nAny 'jailbreak' app can access any other app's keychain data (and steal your Dash). Wipe this wallet immediately and restore on a secure device."; +/* Identities: this identity key was disabled on Platform */ +"Disabled" = "Disabled"; + /* Coinbase Entry Point */ "Disconnect Coinbase Account" = "Disconnect Coinbase Account"; @@ -1859,6 +1871,12 @@ /* Usernames */ "Keep your passphrase safe" = "Keep your passphrase safe"; +/* Identities: one identity public key, by its DPP key id */ +"Key #%d" = "Key #%d"; + +/* Identities: the public key bytes */ +"Key data" = "Key data"; + /* No comment provided by engineer. */ /* Masternodes */ "Key ownership" = "Key ownership"; @@ -2185,6 +2203,15 @@ /* CoinJoin */ "Funds moved" = "Funds moved"; +/* Identities: what a public key is used for */ +"Purpose" = "Purpose"; + +/* Identities: key visible to the wallet but not usable for signing */ +"Read-only" = "Read-only"; + +/* Identities: DPP security level of a public key */ +"Security level" = "Security level"; + /* CoinJoin */ "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; From b4415f4b8c213255740e1b0fc77dbbbf5cce79cb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:07:49 +0700 Subject: [PATCH 02/15] feat(dashpay): Enable DashPay banner for identities missing contact keys Identities registered before the contact-key policy (or restored with an incomplete key set) can't receive contact requests: DIP-15 ECDH needs an enabled ECDSA ENCRYPTION/DECRYPTION pair on both sides. The Contacts tab now shows an "Enable DashPay" banner when the main identity's persisted keys lack the pair. Tapping opens a confirm sheet explaining that two keys will be added so others can send contact requests, with the fee-schedule-derived cost estimate in DASH and local currency (identity_update min fee + 2x identity_key_in_creation_cost, paid from the identity's credit balance). Confirming runs the PIN gate and the existing DWIdentityKeyUpgrader IdentityUpdate (no-op if Platform already has both keys). Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKContactsService.swift | 63 ++++++- .../Contacts/SwiftUI/ContactsScreen.swift | 174 +++++++++++++++++- DashWallet/en.lproj/Localizable.strings | 21 +++ 3 files changed, 252 insertions(+), 6 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index d49ad2156..65ea7f240 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -739,6 +739,61 @@ final class SwiftDashSDKContactsService: ObservableObject { // MARK: - Internals + /// True when the wallet's main identity exists but its persisted key + /// set lacks an enabled ECDSA ENCRYPTION or DECRYPTION key — the pair + /// DIP-15 contact-request ECDH requires on BOTH sides (without it, + /// other users' clients find no recipient key and can't send requests + /// to this identity). Drives the Contacts tab's "Enable DashPay" + /// affordance. Local-store read only — `enableDashPay()` re-checks + /// Platform's authoritative key set before broadcasting anything. + func mainIdentityNeedsDashPayKeys() -> Bool { + guard let modelContainer = SwiftDashSDKHost.shared.modelContainer, + let ownerId = DWCurrentUserIdentityInfo.shared.identityId else { + return false + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId }) + descriptor.fetchLimit = 1 + guard let identity = (try? modelContainer.mainContext.fetch(descriptor))?.first else { + return false + } + func hasEnabledECDSAKey(purposeRaw: String) -> Bool { + identity.publicKeys.contains { key in + key.purpose == purposeRaw + && key.keyTypeEnum == .ecdsaSecp256k1 + && !key.isDisabled + } + } + let encryption = String(KeyPurpose.encryption.rawValue) + let decryption = String(KeyPurpose.decryption.rawValue) + return !hasEnabledECDSAKey(purposeRaw: encryption) || !hasEnabledECDSAKey(purposeRaw: decryption) + } + + /// Estimated network fee for the enable-DashPay IdentityUpdate, in + /// duffs (1 duff = 1000 credits): the platform fee schedule's + /// `identity_update` minimum (100,000 credits) plus + /// `identity_key_in_creation_cost` (6,500,000 credits) for each of the + /// two added keys — rs-platform-version `state_transition_min_fees` + /// v1. The actual fee is computed at execution and deducted from the + /// identity's credit balance; the confirm sheet labels this as an + /// estimate. + static let enableDashPayEstimatedFeeDuffs: UInt64 = (100_000 + 2 * 6_500_000) / 1000 + + /// PIN-gated "Enable DashPay": one IdentityUpdate adding whichever of + /// the ENCRYPTION/DECRYPTION pair the identity is missing on Platform + /// (`DWIdentityKeyUpgrader` — the same lazy repair the contact actions + /// run). No-op returning false when Platform already has both keys. + func enableDashPay() async throws -> Bool { + let (wallet, modelContainer, ownerId) = try requireContext() + try await authorize() + let upgraded = try await ensureOwnDashPayKeys( + wallet: wallet, + ownerId: ownerId, + modelContainer: modelContainer) + Self.logger.info("👥 CONTACTS :: enable DashPay finished (broadcast=\(upgraded, privacy: .public))") + return upgraded + } + private func requireContext() throws -> (ManagedPlatformWallet, ModelContainer, Data) { guard let wallet = SwiftDashSDKHost.shared.wallet else { throw ServiceError.noWallet @@ -759,12 +814,15 @@ final class SwiftDashSDKContactsService: ObservableObject { /// `acceptContactRequest` need our own enabled ECDSA ENCRYPTION /// key for DIP-15 ECDH. No-op once both keys exist; called after /// the PIN gate so any IdentityUpdate is covered by the same user - /// approval as the contact action it unblocks. + /// approval as the contact action it unblocks. Returns true when + /// an IdentityUpdate was broadcast (the explicit Enable DashPay + /// flow surfaces this; the contact actions ignore it). + @discardableResult private func ensureOwnDashPayKeys( wallet: ManagedPlatformWallet, ownerId: Data, modelContainer: ModelContainer - ) async throws { + ) async throws -> Bool { guard let sdk = SwiftDashSDKHost.shared.sdk, let network = SwiftDashSDKHost.shared.runningNetwork else { throw ServiceError.noWallet @@ -779,6 +837,7 @@ final class SwiftDashSDKContactsService: ObservableObject { if upgraded { Self.logger.info("👥 CONTACTS :: identity upgraded with DashPay keys before contact action") } + return upgraded } catch { Self.logger.error("👥 CONTACTS :: DashPay key upgrade failed: \(String(describing: error), privacy: .public)") throw ServiceError.sdk(error) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 935029794..d1f64c28b 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -29,6 +29,12 @@ final class ContactsViewModel: ObservableObject { @Published var processingIds: Set = [] @Published var errorMessage: String? = nil + /// True while the main identity's persisted key set lacks the DIP-15 + /// ENCRYPTION/DECRYPTION pair — without it nobody can send this + /// identity a contact request. Drives the "Enable DashPay" banner. + @Published var needsDashPayEnable = false + @Published var isEnablingDashPay = false + private let service = SwiftDashSDKContactsService.shared init() { @@ -45,6 +51,36 @@ final class ContactsViewModel: ObservableObject { func refresh() { service.refresh() + needsDashPayEnable = service.mainIdentityNeedsDashPayKeys() + } + + /// Estimated IdentityUpdate fee for the confirm sheet: + /// "~0.0000131 DASH (≈ THB 0.01)" in the user's local currency. + var enableDashPayEstimatedCostText: String { + let duffs = SwiftDashSDKContactsService.enableDashPayEstimatedFeeDuffs + return String.localizedStringWithFormat( + NSLocalizedString("~%@ DASH (≈ %@)", comment: "DashPay: estimated network fee — DASH amount, then its local-currency equivalent"), + duffs.formattedDashAmountWithoutCurrencySymbol, + CurrencyExchanger.shared.fiatAmountString(for: duffs.dashAmount)) + } + + /// PIN-gated IdentityUpdate adding the missing contact-request keys. + /// On success the banner clears optimistically (Platform accepted the + /// broadcast, or already had the keys). + func enableDashPay() { + guard !isEnablingDashPay else { return } + isEnablingDashPay = true + Task { + defer { isEnablingDashPay = false } + do { + _ = try await service.enableDashPay() + needsDashPayEnable = false + } catch SwiftDashSDKContactsService.ServiceError.authCancelled { + // User backed out of the PIN prompt — not an error state. + } catch { + errorMessage = error.localizedDescription + } + } } func syncNow() async { @@ -123,6 +159,7 @@ struct ContactsScreen: View { @StateObject private var viewModel = ContactsViewModel() @State private var filterText = "" @State private var showingAddContact = false + @State private var showingEnableDashPay = false @State private var selectedContact: ContactItem? = nil var body: some View { @@ -165,10 +202,52 @@ struct ContactsScreen: View { @ViewBuilder private var content: some View { - if viewModel.isEmpty { - emptyState - } else { - list + VStack(spacing: 0) { + if viewModel.needsDashPayEnable { + enableDashPayBanner + } + if viewModel.isEmpty { + emptyState + } else { + list + } + } + } + + /// Shown while the main identity lacks the DIP-15 contact-request key + /// pair: without it, other users can't send this identity a contact + /// request. Tapping opens the fee-confirm sheet. + private var enableDashPayBanner: some View { + Button(action: { showingEnableDashPay = true }) { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.badge.exclamationmark") + .font(.system(size: 22)) + .foregroundColor(.dash.blue) + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(NSLocalizedString("Your identity can't receive contact requests yet", comment: "DashPay: subtitle of the Enable DashPay banner")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .padding(.horizontal, 15) + .padding(.top, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .sheet(isPresented: $showingEnableDashPay) { + EnableDashPayConfirmSheet(viewModel: viewModel) + .presentationDetents([.height(360)]) } } @@ -325,6 +404,93 @@ struct ContactsScreen: View { } } +// MARK: - EnableDashPayConfirmSheet + +/// Fee-confirm sheet for the "Enable DashPay" banner: explains that +/// confirming adds the contact-request key pair to the identity via one +/// IdentityUpdate, shows the schedule-derived fee estimate in DASH and +/// local currency, and hands off to the PIN gate on confirm. The +/// transition is paid from the identity's credit balance. +private struct EnableDashPayConfirmSheet: View { + @ObservedObject var viewModel: ContactsViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Image(systemName: "person.2.badge.key.fill") + .font(.system(size: 34)) + .foregroundColor(.dash.blue) + .frame(width: 72, height: 72) + .background(Circle().fill(Color.dash.blue.opacity(0.08))) + .padding(.top, 28) + + Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.dash.primaryText) + .padding(.top, 16) + + Text(NSLocalizedString( + "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network.", + comment: "DashPay: body of the Enable DashPay confirmation")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + .padding(.top, 8) + + HStack { + Text(NSLocalizedString("Estimated network fee", comment: "DashPay: fee line of the Enable DashPay confirmation")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(viewModel.enableDashPayEstimatedCostText) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.trailing) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .padding(.horizontal, 20) + .padding(.top, 16) + + Text(NSLocalizedString("Paid from your identity's credit balance.", comment: "DashPay: fee source note of the Enable DashPay confirmation")) + .font(.system(size: 12)) + .foregroundColor(.dash.tertiaryText) + .padding(.top, 6) + + Spacer(minLength: 12) + + Button(action: { + dismiss() + viewModel.enableDashPay() + }) { + Text(NSLocalizedString("Enable", comment: "DashPay: confirm button of the Enable DashPay sheet")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .disabled(viewModel.isEnablingDashPay) + .padding(.horizontal, 20) + + Button(action: { dismiss() }) { + Text(NSLocalizedString("Cancel", comment: "")) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + .background(Color.dash.primaryBackground) + } +} + // MARK: - Rows (Android dashpay_contact_row: 70pt, avatar 36, name 17sb) struct ContactRow: View { diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index bd6fb798f..67e2addc6 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -1147,6 +1147,12 @@ /* CrowdNode */ "Email" = "Email"; +/* DashPay: confirm button of the Enable DashPay sheet */ +"Enable" = "Enable"; + +/* DashPay: add the identity keys other users need to send contact requests */ +"Enable DashPay" = "Enable DashPay"; + /* No comment provided by engineer. */ "Enable Face ID" = "Enable Face ID"; @@ -1213,6 +1219,9 @@ /* No comment provided by engineer. */ "Error updating your profile" = "Error updating your profile"; +/* DashPay: fee line of the Enable DashPay confirmation */ +"Estimated network fee" = "Estimated network fee"; + /* SPV sync peer type */ "Evonode" = "Evonode"; @@ -2203,6 +2212,9 @@ /* CoinJoin */ "Funds moved" = "Funds moved"; +/* DashPay: fee source note of the Enable DashPay confirmation */ +"Paid from your identity's credit balance." = "Paid from your identity's credit balance."; + /* Identities: what a public key is used for */ "Purpose" = "Purpose"; @@ -2212,6 +2224,12 @@ /* Identities: DPP security level of a public key */ "Security level" = "Security level"; +/* DashPay: body of the Enable DashPay confirmation */ +"This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network."; + +/* DashPay: subtitle of the Enable DashPay banner */ +"Your identity can't receive contact requests yet" = "Your identity can't receive contact requests yet"; + /* CoinJoin */ "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; @@ -4750,6 +4768,9 @@ /* Savings percentage */ "~%.0f%%" = "~%.0f%%"; +/* DashPay: estimated network fee — DASH amount, then its local-currency equivalent */ +"~%@ DASH (≈ %@)" = "~%@ DASH (≈ %@)"; + /* Usernames */ "“%@” has been registered." = "“%@” has been registered."; From 0a8fed27420da6a19e4ac7fd1b345631a6e7ac93 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:23:18 +0700 Subject: [PATCH 03/15] fix(dashpay): gate add-contact on DashPay keys; banner copy covers send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIP-15 ECDH is required on both sides, so until the identity has its contact-key pair the add-contact toolbar button and empty-state CTA are hidden — the Enable DashPay banner is the call to action — and the banner subtitle says "can't send or receive". Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftUI/ContactsScreen.swift | 30 +++++++++++++------ DashWallet/en.lproj/Localizable.strings | 2 +- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index d1f64c28b..b336b3717 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -170,14 +170,19 @@ struct ContactsScreen: View { } .navigationTitle(NSLocalizedString("Contacts", comment: "DashPay Contacts")) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showingAddContact = true - } label: { - Image(systemName: "person.badge.plus") - .foregroundColor(.dash.blue) + // Adding a contact needs the DIP-15 key pair on our own + // side too (the outgoing request's ECDH) — hide the + // affordance until the identity is DashPay-enabled. + if !viewModel.needsDashPayEnable { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAddContact = true + } label: { + Image(systemName: "person.badge.plus") + .foregroundColor(.dash.blue) + } + .accessibilityLabel(NSLocalizedString("Add a New Contact", comment: "DashPay Contacts")) } - .accessibilityLabel(NSLocalizedString("Add a New Contact", comment: "DashPay Contacts")) } } .sheet(isPresented: $showingAddContact) { @@ -207,7 +212,14 @@ struct ContactsScreen: View { enableDashPayBanner } if viewModel.isEmpty { - emptyState + // The add-contact empty state only once the identity can + // actually exchange requests; until then the banner IS the + // call to action. + if viewModel.needsDashPayEnable { + Spacer() + } else { + emptyState + } } else { list } @@ -227,7 +239,7 @@ struct ContactsScreen: View { Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) .font(.system(size: 15, weight: .semibold)) .foregroundColor(.dash.primaryText) - Text(NSLocalizedString("Your identity can't receive contact requests yet", comment: "DashPay: subtitle of the Enable DashPay banner")) + Text(NSLocalizedString("Your identity can't send or receive contact requests yet", comment: "DashPay: subtitle of the Enable DashPay banner")) .font(.system(size: 13)) .foregroundColor(.dash.secondaryText) } diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 67e2addc6..5a8fd31e8 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2228,7 +2228,7 @@ "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network."; /* DashPay: subtitle of the Enable DashPay banner */ -"Your identity can't receive contact requests yet" = "Your identity can't receive contact requests yet"; +"Your identity can't send or receive contact requests yet" = "Your identity can't send or receive contact requests yet"; /* CoinJoin */ "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; From 45d2623af85c114d215c429550504d0b66a61656 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:38:30 +0700 Subject: [PATCH 04/15] fix(dashpay): Enable DashPay sheet layout and copy Vertical fee card with wrap-enabled value (the fiat estimate truncated in the 360pt detent), taller detent, non-clipping body text, and the body now ends "This is a one time event." Co-Authored-By: Claude Fable 5 --- .../DashPay/Contacts/SwiftUI/ContactsScreen.swift | 15 ++++++++------- DashWallet/en.lproj/Localizable.strings | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index b336b3717..0a995d986 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -259,7 +259,7 @@ struct ContactsScreen: View { .buttonStyle(.plain) .sheet(isPresented: $showingEnableDashPay) { EnableDashPayConfirmSheet(viewModel: viewModel) - .presentationDetents([.height(360)]) + .presentationDetents([.height(470)]) } } @@ -442,24 +442,25 @@ private struct EnableDashPayConfirmSheet: View { .padding(.top, 16) Text(NSLocalizedString( - "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network.", + "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event.", comment: "DashPay: body of the Enable DashPay confirmation")) .font(.system(size: 14)) .foregroundColor(.dash.secondaryText) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 28) .padding(.top, 8) - HStack { + VStack(alignment: .leading, spacing: 4) { Text(NSLocalizedString("Estimated network fee", comment: "DashPay: fee line of the Enable DashPay confirmation")) - .font(.system(size: 14)) + .font(.caption) .foregroundColor(.dash.secondaryText) - Spacer() Text(viewModel.enableDashPayEstimatedCostText) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundColor(.dash.primaryText) - .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 5a8fd31e8..7015cd37a 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -2225,7 +2225,7 @@ "Security level" = "Security level"; /* DashPay: body of the Enable DashPay confirmation */ -"This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. It happens once, on the Dash Platform network."; +"This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event."; /* DashPay: subtitle of the Enable DashPay banner */ "Your identity can't send or receive contact requests yet" = "Your identity can't send or receive contact requests yet"; From b99a2aaa7de25f0c4da4645eff4dd958e5f31a47 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:42:35 +0700 Subject: [PATCH 05/15] feat(dashpay): pre-enable DashPay intro with FAQ on the Contacts tab Until the identity has its contact-key pair, the tab reads "DashPay" instead of "Contacts" and pitches the experience: usernames over addresses, end-to-end-encrypted contact requests, per-contact history, and a coming-soon note for Shielded DashPay and pay-to-non-contact. A Learn More sheet answers FAQ items (what DashPay is, why enabling is needed, privacy, cost, roadmap). The Enable CTA opens the existing fee-confirm sheet; the banner it replaces is gone. Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftUI/ContactsScreen.swift | 267 ++++++++++++++---- DashWallet/en.lproj/Localizable.strings | 70 ++++- 2 files changed, 282 insertions(+), 55 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 0a995d986..46e8a95b9 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -168,7 +168,9 @@ struct ContactsScreen: View { Color.dash.primaryBackground.ignoresSafeArea() content } - .navigationTitle(NSLocalizedString("Contacts", comment: "DashPay Contacts")) + .navigationTitle(viewModel.needsDashPayEnable + ? NSLocalizedString("DashPay", comment: "DashPay") + : NSLocalizedString("Contacts", comment: "DashPay Contacts")) .toolbar { // Adding a contact needs the DIP-15 key pair on our own // side too (the outgoing request's ECDH) — hide the @@ -207,59 +209,22 @@ struct ContactsScreen: View { @ViewBuilder private var content: some View { - VStack(spacing: 0) { - if viewModel.needsDashPayEnable { - enableDashPayBanner - } - if viewModel.isEmpty { - // The add-contact empty state only once the identity can - // actually exchange requests; until then the banner IS the - // call to action. - if viewModel.needsDashPayEnable { - Spacer() - } else { - emptyState - } - } else { - list - } - } - } - - /// Shown while the main identity lacks the DIP-15 contact-request key - /// pair: without it, other users can't send this identity a contact - /// request. Tapping opens the fee-confirm sheet. - private var enableDashPayBanner: some View { - Button(action: { showingEnableDashPay = true }) { - HStack(spacing: 12) { - Image(systemName: "person.crop.circle.badge.exclamationmark") - .font(.system(size: 22)) - .foregroundColor(.dash.blue) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Text(NSLocalizedString("Your identity can't send or receive contact requests yet", comment: "DashPay: subtitle of the Enable DashPay banner")) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) + if viewModel.needsDashPayEnable { + // Until the identity has its contact-key pair the tab is the + // DashPay pitch: what it is, why enable it, and the one CTA. + // The contacts UI (and its add affordances) appear only once + // requests can actually be exchanged. + DashPayIntroView( + viewModel: viewModel, + showingEnableDashPay: $showingEnableDashPay) + .sheet(isPresented: $showingEnableDashPay) { + EnableDashPayConfirmSheet(viewModel: viewModel) + .presentationDetents([.height(470)]) } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.dash.secondaryText) - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.dash.secondaryBackground)) - .padding(.horizontal, 15) - .padding(.top, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .sheet(isPresented: $showingEnableDashPay) { - EnableDashPayConfirmSheet(viewModel: viewModel) - .presentationDetents([.height(470)]) + } else if viewModel.isEmpty { + emptyState + } else { + list } } @@ -416,6 +381,202 @@ struct ContactsScreen: View { } } +// MARK: - DashPayIntroView + +/// The Contacts tab's pre-enable takeover: pitches DashPay, links the +/// FAQ, and carries the single Enable CTA. Shown while the main identity +/// lacks the DIP-15 contact-key pair (it can neither send nor receive +/// contact requests until then). +private struct DashPayIntroView: View { + @ObservedObject var viewModel: ContactsViewModel + @Binding var showingEnableDashPay: Bool + @State private var showingFAQ = false + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + Image(systemName: "person.2.wave.2.fill") + .font(.system(size: 40)) + .foregroundColor(.dash.blue) + .frame(width: 96, height: 96) + .background(Circle().fill(Color.dash.blue.opacity(0.08))) + .padding(.top, 12) + + Text(NSLocalizedString("Pay people, not addresses", comment: "DashPay intro: headline")) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + .padding(.top, 16) + + Text(NSLocalizedString( + "DashPay replaces long cryptic addresses with usernames. Add friends as contacts, send money to a name, and keep every payment organized by person.", + comment: "DashPay intro: pitch paragraph")) + .font(.system(size: 15)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + VStack(spacing: 14) { + featureRow( + icon: "at", + title: NSLocalizedString("Usernames, not addresses", comment: "DashPay intro: feature title"), + body: NSLocalizedString("Send to a username you can actually remember and verify.", comment: "DashPay intro: feature body")) + featureRow( + icon: "lock.shield.fill", + title: NSLocalizedString("Private by design", comment: "DashPay intro: feature title"), + body: NSLocalizedString("Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts.", comment: "DashPay intro: feature body")) + featureRow( + icon: "clock.arrow.circlepath", + title: NSLocalizedString("Your history, organized", comment: "DashPay intro: feature title"), + body: NSLocalizedString("Payments with each contact are grouped in one place, with names and profiles instead of raw transactions.", comment: "DashPay intro: feature body")) + featureRow( + icon: "sparkles", + title: NSLocalizedString("Coming soon", comment: "DashPay intro: feature title"), + body: NSLocalizedString("Shielded DashPay and paying people who aren't contacts yet.", comment: "DashPay intro: coming-soon body")) + } + .padding(.horizontal, 20) + .padding(.top, 20) + + Button(action: { showingFAQ = true }) { + Text(NSLocalizedString("Learn More", comment: "DashPay intro: opens the FAQ")) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.dash.blue) + } + .padding(.top, 16) + .padding(.bottom, 12) + } + } + + Button(action: { showingEnableDashPay = true }) { + Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .disabled(viewModel.isEnablingDashPay) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + .sheet(isPresented: $showingFAQ) { + DashPayFAQSheet() + } + } + + private func featureRow(icon: String, title: String, body: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.dash.blue) + .frame(width: 36, height: 36) + .background(Circle().fill(Color.dash.blue.opacity(0.08))) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(body) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } +} + +// MARK: - DashPayFAQSheet + +/// "Learn More" FAQ for the DashPay intro: what DashPay is, why enabling +/// is needed, privacy, cost, and what's coming next. +private struct DashPayFAQSheet: View { + @Environment(\.dismiss) private var dismiss + + private struct Item: Identifiable { + let id = UUID() + let question: String + let answer: String + } + + private var items: [Item] { + [ + Item( + question: NSLocalizedString("What is DashPay?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "DashPay is Dash's social payments experience, built on Dash Platform. You register a username, add other users as contacts, and pay them by name — no more copying addresses.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("Why do I need to enable it?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("How private is DashPay?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("What does it cost?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Enabling DashPay costs a small one-time network fee, paid from your identity's credit balance. The exact estimate is shown before you confirm. Sending contact requests and payments costs the usual network fees.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("What's coming next?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon.", + comment: "DashPay FAQ")), + ] + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 10) { + ForEach(items) { item in + DisclosureGroup { + Text(item.answer) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } label: { + Text(item.question) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.leading) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 24) + } + .background(Color.dash.primaryBackground) + .navigationTitle(NSLocalizedString("About DashPay", comment: "DashPay FAQ title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("Done", comment: "")) { dismiss() } + } + } + } + } +} + // MARK: - EnableDashPayConfirmSheet /// Fee-confirm sheet for the "Enable DashPay" banner: explains that diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 7015cd37a..78d285a6a 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -179,6 +179,9 @@ /* No comment provided by engineer. */ "About" = "About"; +/* DashPay FAQ title */ +"About DashPay" = "About DashPay"; + /* No comment provided by engineer. */ "About me" = "About me"; @@ -635,12 +638,21 @@ /* Masternodes */ "Collateral" = "Collateral"; +/* DashPay intro: feature title */ +"Coming soon" = "Coming soon"; + /* Shielded transfer status */ "Completed" = "Completed"; /* Balance info sheet section */ "Cons" = "Cons"; +/* DashPay FAQ */ +"Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform." = "Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform."; + +/* DashPay intro: feature body */ +"Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts." = "Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts."; + /* Raw transaction inspector */ "Copy Raw Transaction" = "Copy Raw Transaction"; @@ -982,9 +994,18 @@ /* Buy Dash */ "Dash Wallet on this device" = "Dash Wallet on this device"; +/* DashPay */ +"DashPay" = "DashPay"; + /* No comment provided by engineer. */ "DashPay Invitation" = "DashPay Invitation"; +/* DashPay FAQ */ +"DashPay is Dash's social payments experience, built on Dash Platform. You register a username, add other users as contacts, and pay them by name — no more copying addresses." = "DashPay is Dash's social payments experience, built on Dash Platform. You register a username, add other users as contacts, and pay them by name — no more copying addresses."; + +/* DashPay intro: pitch paragraph */ +"DashPay replaces long cryptic addresses with usernames. Add friends as contacts, send money to a name, and keep every payment organized by person." = "DashPay replaces long cryptic addresses with usernames. Add friends as contacts, send money to a name, and keep every payment organized by person."; + /* No comment provided by engineer. */ "DashPay Upgrade Fee" = "DashPay Upgrade Fee"; @@ -1159,6 +1180,9 @@ /* No comment provided by engineer. */ "Enable Touch ID" = "Enable Touch ID"; +/* DashPay FAQ */ +"Enabling DashPay costs a small one-time network fee, paid from your identity's credit balance. The exact estimate is shown before you confirm. Sending contact requests and payments costs the usual network fees." = "Enabling DashPay costs a small one-time network fee, paid from your identity's credit balance. The exact estimate is shown before you confirm. Sending contact requests and payments costs the usual network fees."; + /* Swap */ "Enter a valid %@ address. %@ here is on %@, so an Ethereum (0x…) address won’t work." = "Enter a valid %1$@ address. %2$@ here is on %3$@, so an Ethereum (0x…) address won’t work."; @@ -1574,6 +1598,9 @@ /* No comment provided by engineer. */ "How do I get Test Dash?" = "How do I get Test Dash?"; +/* DashPay FAQ */ +"How private is DashPay?" = "How private is DashPay?"; + /* CrowdNode */ "How to confirm your API Dash address" = "How to confirm your API Dash address"; @@ -1914,6 +1941,9 @@ /* Maya */ "Learn more" = "Learn more"; +/* DashPay intro: opens the FAQ */ +"Learn More" = "Learn More"; + /* Info Screen */ "Learn More..." = "Learn More..."; @@ -2215,6 +2245,15 @@ /* DashPay: fee source note of the Enable DashPay confirmation */ "Paid from your identity's credit balance." = "Paid from your identity's credit balance."; +/* DashPay intro: headline */ +"Pay people, not addresses" = "Pay people, not addresses"; + +/* DashPay intro: feature body */ +"Payments with each contact are grouped in one place, with names and profiles instead of raw transactions." = "Payments with each contact are grouped in one place, with names and profiles instead of raw transactions."; + +/* DashPay intro: feature title */ +"Private by design" = "Private by design"; + /* Identities: what a public key is used for */ "Purpose" = "Purpose"; @@ -2224,11 +2263,38 @@ /* Identities: DPP security level of a public key */ "Security level" = "Security level"; +/* DashPay intro: feature body */ +"Send to a username you can actually remember and verify." = "Send to a username you can actually remember and verify."; + +/* DashPay intro: coming-soon body */ +"Shielded DashPay and paying people who aren't contacts yet." = "Shielded DashPay and paying people who aren't contacts yet."; + +/* DashPay FAQ */ +"Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon." = "Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon."; + /* DashPay: body of the Enable DashPay confirmation */ "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event."; -/* DashPay: subtitle of the Enable DashPay banner */ -"Your identity can't send or receive contact requests yet" = "Your identity can't send or receive contact requests yet"; +/* DashPay intro: feature title */ +"Usernames, not addresses" = "Usernames, not addresses"; + +/* DashPay FAQ */ +"What does it cost?" = "What does it cost?"; + +/* DashPay FAQ */ +"What is DashPay?" = "What is DashPay?"; + +/* DashPay FAQ */ +"What's coming next?" = "What's coming next?"; + +/* DashPay FAQ */ +"Why do I need to enable it?" = "Why do I need to enable it?"; + +/* DashPay intro: feature title */ +"Your history, organized" = "Your history, organized"; + +/* DashPay FAQ */ +"Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event." = "Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event."; /* CoinJoin */ "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds." = "Your mixed coins were moved to your Shielded balance. For best privacy, wait at least 2 hours before using these funds."; From a97a2392135c858d20da52054ef851e1de1d168d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:53:41 +0700 Subject: [PATCH 06/15] =?UTF-8?q?fix(dashpay):=20honest=20privacy=20copy?= =?UTF-8?q?=20=E2=80=94=20payments=20private,=20requests=20not=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intro card and FAQ now say it plainly: payment addresses travel in an encrypted payload and are never published, but contact requests themselves are currently public (anyone can see two identities are connected). Private contact requests join Shielded DashPay and pay-to-non-contact in the coming-soon list. Co-Authored-By: Claude Fable 5 --- .../UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 10 +++++----- DashWallet/en.lproj/Localizable.strings | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 46e8a95b9..9f0e0c94f 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -426,8 +426,8 @@ private struct DashPayIntroView: View { body: NSLocalizedString("Send to a username you can actually remember and verify.", comment: "DashPay intro: feature body")) featureRow( icon: "lock.shield.fill", - title: NSLocalizedString("Private by design", comment: "DashPay intro: feature title"), - body: NSLocalizedString("Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts.", comment: "DashPay intro: feature body")) + title: NSLocalizedString("Private payments", comment: "DashPay intro: feature title"), + body: NSLocalizedString("Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet.", comment: "DashPay intro: feature body")) featureRow( icon: "clock.arrow.circlepath", title: NSLocalizedString("Your history, organized", comment: "DashPay intro: feature title"), @@ -435,7 +435,7 @@ private struct DashPayIntroView: View { featureRow( icon: "sparkles", title: NSLocalizedString("Coming soon", comment: "DashPay intro: feature title"), - body: NSLocalizedString("Shielded DashPay and paying people who aren't contacts yet.", comment: "DashPay intro: coming-soon body")) + body: NSLocalizedString("Private contact requests, Shielded DashPay, and paying people who aren't contacts yet.", comment: "DashPay intro: coming-soon body")) } .padding(.horizontal, 20) .padding(.top, 20) @@ -522,7 +522,7 @@ private struct DashPayFAQSheet: View { Item( question: NSLocalizedString("How private is DashPay?", comment: "DashPay FAQ"), answer: NSLocalizedString( - "Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform.", + "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform.", comment: "DashPay FAQ")), Item( question: NSLocalizedString("What does it cost?", comment: "DashPay FAQ"), @@ -532,7 +532,7 @@ private struct DashPayFAQSheet: View { Item( question: NSLocalizedString("What's coming next?", comment: "DashPay FAQ"), answer: NSLocalizedString( - "Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon.", + "Private contact requests (so who you connect with stays private), Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are all coming soon.", comment: "DashPay FAQ")), ] } diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 78d285a6a..bcf2461d4 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -648,10 +648,10 @@ "Cons" = "Cons"; /* DashPay FAQ */ -"Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform." = "Contact requests are end-to-end encrypted with keys only you and your contact hold. The payment addresses you exchange are derived from that encrypted handshake and are never published — each contact pays you at fresh addresses only the two of you can link. Your username and profile are public on Dash Platform."; +"Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; /* DashPay intro: feature body */ -"Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts." = "Contact requests are end-to-end encrypted, and fresh payment addresses are shared only between you and your contacts."; +"Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet." = "Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet."; /* Raw transaction inspector */ "Copy Raw Transaction" = "Copy Raw Transaction"; @@ -2252,7 +2252,7 @@ "Payments with each contact are grouped in one place, with names and profiles instead of raw transactions." = "Payments with each contact are grouped in one place, with names and profiles instead of raw transactions."; /* DashPay intro: feature title */ -"Private by design" = "Private by design"; +"Private payments" = "Private payments"; /* Identities: what a public key is used for */ "Purpose" = "Purpose"; @@ -2267,10 +2267,10 @@ "Send to a username you can actually remember and verify." = "Send to a username you can actually remember and verify."; /* DashPay intro: coming-soon body */ -"Shielded DashPay and paying people who aren't contacts yet." = "Shielded DashPay and paying people who aren't contacts yet."; +"Private contact requests, Shielded DashPay, and paying people who aren't contacts yet." = "Private contact requests, Shielded DashPay, and paying people who aren't contacts yet."; /* DashPay FAQ */ -"Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon." = "Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are both coming soon."; +"Private contact requests (so who you connect with stays private), Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are all coming soon." = "Private contact requests (so who you connect with stays private), Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are all coming soon."; /* DashPay: body of the Enable DashPay confirmation */ "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event."; From 91cfd89b9539811300b029a822daeedc07227e1f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 23:56:31 +0700 Subject: [PATCH 07/15] =?UTF-8?q?fix(dashpay):=20privacy=20copy=20?= =?UTF-8?q?=E2=80=94=20private=20but=20not=20shielded,=20chain=20analysis?= =?UTF-8?q?=20caveat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payments to contacts go to unpublished encrypted-exchanged addresses, so they aren't trivially linkable to the username — but they're still transparent-chain payments and chain analysis may leak information; Shielded DashPay (coming soon) closes that gap. Co-Authored-By: Claude Fable 5 --- .../Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 4 ++-- DashWallet/en.lproj/Localizable.strings | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 9f0e0c94f..ed244deef 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -427,7 +427,7 @@ private struct DashPayIntroView: View { featureRow( icon: "lock.shield.fill", title: NSLocalizedString("Private payments", comment: "DashPay intro: feature title"), - body: NSLocalizedString("Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet.", comment: "DashPay intro: feature body")) + body: NSLocalizedString("Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either.", comment: "DashPay intro: feature body")) featureRow( icon: "clock.arrow.circlepath", title: NSLocalizedString("Your history, organized", comment: "DashPay intro: feature title"), @@ -522,7 +522,7 @@ private struct DashPayFAQSheet: View { Item( question: NSLocalizedString("How private is DashPay?", comment: "DashPay FAQ"), answer: NSLocalizedString( - "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform.", + "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform.", comment: "DashPay FAQ")), Item( question: NSLocalizedString("What does it cost?", comment: "DashPay FAQ"), diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index bcf2461d4..4af3bfa86 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -648,10 +648,10 @@ "Cons" = "Cons"; /* DashPay FAQ */ -"Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — outside observers can't link your payments to your username. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; +"Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; /* DashPay intro: feature body */ -"Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet." = "Payment addresses are exchanged encrypted and never published — outsiders can't link the payments between you and your contacts. Contact requests themselves aren't private yet."; +"Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either." = "Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either."; /* Raw transaction inspector */ "Copy Raw Transaction" = "Copy Raw Transaction"; From af09ee0a10bac0c567db8787c2e86e712ee0fcf8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:02:54 +0700 Subject: [PATCH 08/15] fix(dashpay): intro privacy card copy per owner wording "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves." The shielded/chain-analysis nuance stays in the FAQ's privacy answer. Co-Authored-By: Claude Fable 5 --- .../Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 2 +- DashWallet/en.lproj/Localizable.strings | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index ed244deef..031728ca4 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -427,7 +427,7 @@ private struct DashPayIntroView: View { featureRow( icon: "lock.shield.fill", title: NSLocalizedString("Private payments", comment: "DashPay intro: feature title"), - body: NSLocalizedString("Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either.", comment: "DashPay intro: feature body")) + body: NSLocalizedString("Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves.", comment: "DashPay intro: feature body")) featureRow( icon: "clock.arrow.circlepath", title: NSLocalizedString("Your history, organized", comment: "DashPay intro: feature title"), diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 4af3bfa86..3b6671a8c 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -651,7 +651,7 @@ "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; /* DashPay intro: feature body */ -"Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either." = "Payment addresses are exchanged encrypted and never published, so payments aren't trivially linkable to your username — though they aren't shielded yet, and contact requests aren't private yet either."; +"Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves." = "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves."; /* Raw transaction inspector */ "Copy Raw Transaction" = "Copy Raw Transaction"; From 4c5b61ac8013e33b6501c3b1699afdc9bf184da7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:06:41 +0700 Subject: [PATCH 09/15] =?UTF-8?q?feat(dashpay):=20FAQ=20comparisons=20?= =?UTF-8?q?=E2=80=94=20Bitcoin,=20Ethereum=20accounts,=20name=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new privacy-comparison answers: Bitcoin (open address sharing and reuse vs private per-contact rotating addresses; chain analysis applies to both), Ethereum accounts (one reusable address with a public history, ENS pointing straight at it, vs names resolving to identities), and Unstoppable-Domains-style name services (name -> fixed public address vs a username that never publicly resolves to a payment address). Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftUI/ContactsScreen.swift | 15 +++++++++++++++ DashWallet/en.lproj/Localizable.strings | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 031728ca4..4ea3a2766 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -524,6 +524,21 @@ private struct DashPayFAQSheet: View { answer: NSLocalizedString( "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform.", comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("How does this compare to Bitcoin in terms of privacy?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "On Bitcoin there is no name layer, so people share and reuse addresses out in the open — once an address is known, everything it ever received is linkable to its owner. With DashPay your username is public, but it never points at a payment address: addresses are exchanged privately per contact and rotate, so paying by name doesn't publish where your money goes. Both are transparent chains, so chain analysis still applies to the coins themselves.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("How does this compare to Ethereum Accounts in terms of privacy?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "An Ethereum account is one reusable address: your entire balance and payment history sit publicly under it, and a name (like an ENS domain) typically points straight at that address for anyone to resolve. DashPay is the opposite shape — the name resolves to an identity, not a payment address, and actual payment addresses stay inside encrypted contact exchanges, fresh for each contact.", + comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("What about Unstoppable Domains and similar name services?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Name services like Unstoppable Domains map a human-readable name to a fixed public address on-chain. Anyone can resolve the name and see every payment ever made to it. A DashPay username never publicly resolves to a payment address — addresses are revealed only inside the encrypted exchange with each contact, so your name and your money stay unlinked to outside observers.", + comment: "DashPay FAQ")), Item( question: NSLocalizedString("What does it cost?", comment: "DashPay FAQ"), answer: NSLocalizedString( diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 3b6671a8c..cc278f224 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -327,6 +327,9 @@ /* No comment provided by engineer. */ "An error occurred" = "An error occurred"; +/* DashPay FAQ */ +"An Ethereum account is one reusable address: your entire balance and payment history sit publicly under it, and a name (like an ENS domain) typically points straight at that address for anyone to resolve. DashPay is the opposite shape — the name resolves to an identity, not a payment address, and actual payment addresses stay inside encrypted contact exchanges, fresh for each contact." = "An Ethereum account is one reusable address: your entire balance and payment history sit publicly under it, and a name (like an ENS domain) typically points straight at that address for anyone to resolve. DashPay is the opposite shape — the name resolves to an identity, not a payment address, and actual payment addresses stay inside encrypted contact exchanges, fresh for each contact."; + /* No comment provided by engineer. */ "An intuitive and familiar experience across all your devices" = "An intuitive and familiar experience across all your devices"; @@ -647,6 +650,18 @@ /* Balance info sheet section */ "Cons" = "Cons"; +/* DashPay FAQ */ +"How does this compare to Bitcoin in terms of privacy?" = "How does this compare to Bitcoin in terms of privacy?"; + +/* DashPay FAQ */ +"How does this compare to Ethereum Accounts in terms of privacy?" = "How does this compare to Ethereum Accounts in terms of privacy?"; + +/* DashPay FAQ */ +"Name services like Unstoppable Domains map a human-readable name to a fixed public address on-chain. Anyone can resolve the name and see every payment ever made to it. A DashPay username never publicly resolves to a payment address — addresses are revealed only inside the encrypted exchange with each contact, so your name and your money stay unlinked to outside observers." = "Name services like Unstoppable Domains map a human-readable name to a fixed public address on-chain. Anyone can resolve the name and see every payment ever made to it. A DashPay username never publicly resolves to a payment address — addresses are revealed only inside the encrypted exchange with each contact, so your name and your money stay unlinked to outside observers."; + +/* DashPay FAQ */ +"On Bitcoin there is no name layer, so people share and reuse addresses out in the open — once an address is known, everything it ever received is linkable to its owner. With DashPay your username is public, but it never points at a payment address: addresses are exchanged privately per contact and rotate, so paying by name doesn't publish where your money goes. Both are transparent chains, so chain analysis still applies to the coins themselves." = "On Bitcoin there is no name layer, so people share and reuse addresses out in the open — once an address is known, everything it ever received is linkable to its owner. With DashPay your username is public, but it never points at a payment address: addresses are exchanged privately per contact and rotate, so paying by name doesn't publish where your money goes. Both are transparent chains, so chain analysis still applies to the coins themselves."; + /* DashPay FAQ */ "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; @@ -2278,6 +2293,9 @@ /* DashPay intro: feature title */ "Usernames, not addresses" = "Usernames, not addresses"; +/* DashPay FAQ */ +"What about Unstoppable Domains and similar name services?" = "What about Unstoppable Domains and similar name services?"; + /* DashPay FAQ */ "What does it cost?" = "What does it cost?"; From 561d8fa14c550ed406f8e4ef935c90f0feba3696 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:31:48 +0700 Subject: [PATCH 10/15] fix(dashpay): stable FAQ row identity so answers stay open Item.id was a fresh UUID per body evaluation, so any unrelated publish rebuilt the ForEach and collapsed open DisclosureGroups mid-read. The question text is the stable identity. Co-Authored-By: Claude Fable 5 --- .../UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 4ea3a2766..ef4bdc11d 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -502,9 +502,14 @@ private struct DashPayFAQSheet: View { @Environment(\.dismiss) private var dismiss private struct Item: Identifiable { - let id = UUID() let question: String let answer: String + + /// Identity must be stable across body re-evaluations (a fresh + /// `UUID()` per rebuild makes ForEach discard the rows and their + /// DisclosureGroups collapse mid-read); the question text is + /// unique and constant. + var id: String { question } } private var items: [Item] { From 6e152faa054a14b1871ad40907d4dc95c6308a75 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:35:46 +0700 Subject: [PATCH 11/15] feat(dashpay): FAQ entry on private contact requests timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "When will contact requests become private?" — in development, expected around September-October 2026; explains today's public-request / encrypted-payload split and what changes when they ship. Co-Authored-By: Claude Fable 5 --- .../UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 5 +++++ DashWallet/en.lproj/Localizable.strings | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index ef4bdc11d..af125e1f7 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -529,6 +529,11 @@ private struct DashPayFAQSheet: View { answer: NSLocalizedString( "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform.", comment: "DashPay FAQ")), + Item( + question: NSLocalizedString("When will contact requests become private?", comment: "DashPay FAQ"), + answer: NSLocalizedString( + "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible.", + comment: "DashPay FAQ")), Item( question: NSLocalizedString("How does this compare to Bitcoin in terms of privacy?", comment: "DashPay FAQ"), answer: NSLocalizedString( diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index cc278f224..49fd48288 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -665,6 +665,9 @@ /* DashPay FAQ */ "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; +/* DashPay FAQ */ +"Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible." = "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible."; + /* DashPay intro: feature body */ "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves." = "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves."; @@ -2305,6 +2308,9 @@ /* DashPay FAQ */ "What's coming next?" = "What's coming next?"; +/* DashPay FAQ */ +"When will contact requests become private?" = "When will contact requests become private?"; + /* DashPay FAQ */ "Why do I need to enable it?" = "Why do I need to enable it?"; From 4599223e59e86eeb7522f5f98934261c594a7459 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:41:05 +0700 Subject: [PATCH 12/15] =?UTF-8?q?fix(dashpay):=20private-requests=20FAQ=20?= =?UTF-8?q?=E2=80=94=20friendship=20visibility=20is=20a=20choice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per owner wording: once private contact requests ship, you will have the choice of having your friendship be public or private. Co-Authored-By: Claude Fable 5 --- .../Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift | 2 +- DashWallet/en.lproj/Localizable.strings | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index af125e1f7..f18af7a2f 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -532,7 +532,7 @@ private struct DashPayFAQSheet: View { Item( question: NSLocalizedString("When will contact requests become private?", comment: "DashPay FAQ"), answer: NSLocalizedString( - "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible.", + "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, you will have the choice of having your friendship be public or private.", comment: "DashPay FAQ")), Item( question: NSLocalizedString("How does this compare to Bitcoin in terms of privacy?", comment: "DashPay FAQ"), diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 49fd48288..deb0bf98e 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -666,7 +666,7 @@ "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published — so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; /* DashPay FAQ */ -"Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible." = "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, who you connect with will no longer be publicly visible."; +"Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, you will have the choice of having your friendship be public or private." = "Private contact requests are in development and expected around September–October 2026. Today the request itself is public — anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, you will have the choice of having your friendship be public or private."; /* DashPay intro: feature body */ "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves." = "Private payment addresses are exchanged between you and your contacts. Only you and your contact know the recipient and sender of payments between yourselves."; From 622b478d376f525a281b7f5bd06b97209f896c00 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 00:46:33 +0700 Subject: [PATCH 13/15] feat(dashpay): success sheet after enabling with first-request CTA A successful enable now presents "DashPay enabled" with a "Send your first contact request" CTA that chains into AddContactScreen (via onDismiss, so the two sheets don't collide), plus a Not-now dismiss. Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftUI/ContactsScreen.swift | 91 ++++++++++++++++++- DashWallet/en.lproj/Localizable.strings | 9 ++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index f18af7a2f..62615aff2 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -64,9 +64,13 @@ final class ContactsViewModel: ObservableObject { CurrencyExchanger.shared.fiatAmountString(for: duffs.dashAmount)) } + /// True right after a successful enable — drives the one-off success + /// sheet with the "Send your first contact request" CTA. + @Published var showEnableSuccess = false + /// PIN-gated IdentityUpdate adding the missing contact-request keys. - /// On success the banner clears optimistically (Platform accepted the - /// broadcast, or already had the keys). + /// On success the intro clears (Platform accepted the broadcast, or + /// already had the keys) and the success sheet presents. func enableDashPay() { guard !isEnablingDashPay else { return } isEnablingDashPay = true @@ -75,6 +79,7 @@ final class ContactsViewModel: ObservableObject { do { _ = try await service.enableDashPay() needsDashPayEnable = false + showEnableSuccess = true } catch SwiftDashSDKContactsService.ServiceError.authCancelled { // User backed out of the PIN prompt — not an error state. } catch { @@ -160,6 +165,9 @@ struct ContactsScreen: View { @State private var filterText = "" @State private var showingAddContact = false @State private var showingEnableDashPay = false + /// Set by the success sheet's CTA; consumed on its dismissal to open + /// the add-contact sheet. + @State private var pendingFirstContactRequest = false @State private var selectedContact: ContactItem? = nil var body: some View { @@ -190,6 +198,24 @@ struct ContactsScreen: View { .sheet(isPresented: $showingAddContact) { AddContactScreen() } + .sheet( + isPresented: $viewModel.showEnableSuccess, + onDismiss: { + // Chain into the add-contact sheet only after this one + // is fully gone — presenting both at once drops the + // second. + if pendingFirstContactRequest { + pendingFirstContactRequest = false + showingAddContact = true + } + } + ) { + EnableDashPaySuccessSheet(onSendFirstRequest: { + pendingFirstContactRequest = true + viewModel.showEnableSuccess = false + }) + .presentationDetents([.height(420)]) + } .sheet(item: $selectedContact) { contact in ContactProfileSheet(contact: contact) } @@ -602,6 +628,67 @@ private struct DashPayFAQSheet: View { } } +// MARK: - EnableDashPaySuccessSheet + +/// Shown once, right after the enable IdentityUpdate succeeds: confirms +/// the identity can now exchange contact requests and offers the first +/// action. The CTA dismisses this sheet and chains into AddContactScreen +/// via the presenter's onDismiss. +private struct EnableDashPaySuccessSheet: View { + let onSendFirstRequest: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 40)) + .foregroundColor(.dash.green) + .frame(width: 88, height: 88) + .background(Circle().fill(Color.dash.green.opacity(0.1))) + .padding(.top, 28) + + Text(NSLocalizedString("DashPay enabled", comment: "DashPay: title of the enable success sheet")) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.dash.primaryText) + .padding(.top, 16) + + Text(NSLocalizedString( + "You're all set. Other users can now send you contact requests — and you can send yours.", + comment: "DashPay: body of the enable success sheet")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + Spacer(minLength: 12) + + Button(action: onSendFirstRequest) { + Text(NSLocalizedString("Send your first contact request", comment: "DashPay: CTA of the enable success sheet")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .padding(.horizontal, 20) + + Button(action: { dismiss() }) { + Text(NSLocalizedString("Not now", comment: "DashPay: dismiss button of the enable success sheet")) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + .background(Color.dash.primaryBackground) + } +} + // MARK: - EnableDashPayConfirmSheet /// Fee-confirm sheet for the "Enable DashPay" banner: explains that diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index deb0bf98e..a34b15705 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -650,6 +650,9 @@ /* Balance info sheet section */ "Cons" = "Cons"; +/* DashPay: title of the enable success sheet */ +"DashPay enabled" = "DashPay enabled"; + /* DashPay FAQ */ "How does this compare to Bitcoin in terms of privacy?" = "How does this compare to Bitcoin in terms of privacy?"; @@ -2290,6 +2293,9 @@ /* DashPay FAQ */ "Private contact requests (so who you connect with stays private), Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are all coming soon." = "Private contact requests (so who you connect with stays private), Shielded DashPay — contact payments from your private Shielded balance — and paying users who aren't in your contacts yet are all coming soon."; +/* DashPay: CTA of the enable success sheet */ +"Send your first contact request" = "Send your first contact request"; + /* DashPay: body of the Enable DashPay confirmation */ "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event."; @@ -2314,6 +2320,9 @@ /* DashPay FAQ */ "Why do I need to enable it?" = "Why do I need to enable it?"; +/* DashPay: body of the enable success sheet */ +"You're all set. Other users can now send you contact requests — and you can send yours." = "You're all set. Other users can now send you contact requests — and you can send yours."; + /* DashPay intro: feature title */ "Your history, organized" = "Your history, organized"; From 77d1a6ac0a5a275ae085f6fc73273cdf78efe091 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 01:17:10 +0700 Subject: [PATCH 14/15] fix(dashpay): prominent send-success card in Add Contact The post-send confirmation was a 1.5s thin capsule at the sheet bottom - easy to miss entirely. Success now shows a centered card with a green checkmark and "Contact request sent to " for 2.5s; the row spinner continues to cover the in-flight window. Co-Authored-By: Claude Fable 5 --- .../Contacts/SwiftUI/AddContactScreen.swift | 39 ++++++++++++------- DashWallet/en.lproj/Localizable.strings | 3 ++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift index 2a30b856e..4f3d4afd1 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift @@ -36,7 +36,9 @@ struct AddContactScreen: View { /// send/accept confirmation surface). @State private var previewTarget: DpnsSearchResult? = nil @State private var errorMessage: String? = nil - @State private var sentToast = false + /// Username of the recipient of a just-sent request — drives the + /// centered success card (nil = hidden). + @State private var sentToUsername: String? = nil @ObservedObject private var service = SwiftDashSDKContactsService.shared @@ -83,15 +85,26 @@ struct AddContactScreen: View { onSend: { send(to: target) }, onAccept: { accept(target) }) } - .overlay(alignment: .bottom) { - if sentToast { - Text(NSLocalizedString("Contact request sent", comment: "DashPay Contacts")) - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.thinMaterial, in: Capsule()) - .padding(.bottom, 24) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .overlay { + if let username = sentToUsername { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundColor(.dashGreen) + Text(String( + format: NSLocalizedString("Contact request sent to %@", comment: "DashPay Contacts: success card after sending a request"), + username)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + } + .padding(28) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.dash.secondaryBackground) + .shadow(color: Color.dash.shadow, radius: 24, x: 0, y: 8)) + .padding(.horizontal, 40) + .transition(.scale(scale: 0.9).combined(with: .opacity)) } } } @@ -321,9 +334,9 @@ struct AddContactScreen: View { try await service.sendContactRequest( to: target.identityId, usernameHint: target.fullName) - withAnimation { sentToast = true } - try? await Task.sleep(nanoseconds: 1_500_000_000) - withAnimation { sentToast = false } + withAnimation { sentToUsername = target.fullName.withoutDashSuffix } + try? await Task.sleep(nanoseconds: 2_500_000_000) + withAnimation { sentToUsername = nil } } catch SwiftDashSDKContactsService.ServiceError.authCancelled { // User backed out of the PIN prompt. } catch { diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index a34b15705..4a70191d3 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -650,6 +650,9 @@ /* Balance info sheet section */ "Cons" = "Cons"; +/* DashPay Contacts: success card after sending a request */ +"Contact request sent to %@" = "Contact request sent to %@"; + /* DashPay: title of the enable success sheet */ "DashPay enabled" = "DashPay enabled"; From 9d08426bf38587d08491c172fe5e5da0aba2d4e3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 01:44:25 +0700 Subject: [PATCH 15/15] fix(dashpay): address PR #941 review findings - Fee estimate and copy sized to the keys actually missing: missingDashPayKeyCount() (0-2) replaces the boolean check, the schedule estimate takes the count, and the confirm/FAQ copy no longer hardcodes "two keys". - Enable/success sheets scroll and use adaptive detents so every action stays reachable at large Dynamic Type sizes. - A send-success card is only cleared by its own recipient's timer, so back-to-back requests can't hide a newer card early. - SwiftLint: labeled label: closures on the new multi-closure Buttons; drop the redundant optional initializer on sentToUsername. Co-Authored-By: Claude Fable 5 --- .../SwiftDashSDKContactsService.swift | 41 +++++----- .../Contacts/SwiftUI/AddContactScreen.swift | 13 +++- .../Contacts/SwiftUI/ContactsScreen.swift | 76 ++++++++++++++----- .../Security/Wallets/IdentitiesScreen.swift | 8 +- 4 files changed, 93 insertions(+), 45 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 65ea7f240..54a592d04 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -739,23 +739,24 @@ final class SwiftDashSDKContactsService: ObservableObject { // MARK: - Internals - /// True when the wallet's main identity exists but its persisted key - /// set lacks an enabled ECDSA ENCRYPTION or DECRYPTION key — the pair - /// DIP-15 contact-request ECDH requires on BOTH sides (without it, - /// other users' clients find no recipient key and can't send requests - /// to this identity). Drives the Contacts tab's "Enable DashPay" - /// affordance. Local-store read only — `enableDashPay()` re-checks - /// Platform's authoritative key set before broadcasting anything. - func mainIdentityNeedsDashPayKeys() -> Bool { + /// How many of the DIP-15 contact-request keys (an enabled ECDSA + /// ENCRYPTION and DECRYPTION key — required on BOTH sides of the ECDH; + /// without them, other users' clients find no recipient key and can't + /// send requests to this identity) the wallet's main identity is + /// missing: 0 (fully enabled), 1, or 2. Drives the Contacts tab's + /// "Enable DashPay" affordance and its fee estimate. Local-store read + /// only — `enableDashPay()` re-checks Platform's authoritative key set + /// before broadcasting anything. + func missingDashPayKeyCount() -> Int { guard let modelContainer = SwiftDashSDKHost.shared.modelContainer, let ownerId = DWCurrentUserIdentityInfo.shared.identityId else { - return false + return 0 } var descriptor = FetchDescriptor( predicate: #Predicate { $0.identityId == ownerId }) descriptor.fetchLimit = 1 guard let identity = (try? modelContainer.mainContext.fetch(descriptor))?.first else { - return false + return 0 } func hasEnabledECDSAKey(purposeRaw: String) -> Bool { identity.publicKeys.contains { key in @@ -764,20 +765,22 @@ final class SwiftDashSDKContactsService: ObservableObject { && !key.isDisabled } } - let encryption = String(KeyPurpose.encryption.rawValue) - let decryption = String(KeyPurpose.decryption.rawValue) - return !hasEnabledECDSAKey(purposeRaw: encryption) || !hasEnabledECDSAKey(purposeRaw: decryption) + var missing = 0 + if !hasEnabledECDSAKey(purposeRaw: String(KeyPurpose.encryption.rawValue)) { missing += 1 } + if !hasEnabledECDSAKey(purposeRaw: String(KeyPurpose.decryption.rawValue)) { missing += 1 } + return missing } /// Estimated network fee for the enable-DashPay IdentityUpdate, in /// duffs (1 duff = 1000 credits): the platform fee schedule's /// `identity_update` minimum (100,000 credits) plus - /// `identity_key_in_creation_cost` (6,500,000 credits) for each of the - /// two added keys — rs-platform-version `state_transition_min_fees` - /// v1. The actual fee is computed at execution and deducted from the - /// identity's credit balance; the confirm sheet labels this as an - /// estimate. - static let enableDashPayEstimatedFeeDuffs: UInt64 = (100_000 + 2 * 6_500_000) / 1000 + /// `identity_key_in_creation_cost` (6,500,000 credits) per added key — + /// rs-platform-version `state_transition_min_fees` v1. The actual fee + /// is computed at execution and deducted from the identity's credit + /// balance; the confirm sheet labels this as an estimate. + static func enableDashPayEstimatedFeeDuffs(missingKeyCount: Int) -> UInt64 { + (100_000 + UInt64(max(missingKeyCount, 1)) * 6_500_000) / 1000 + } /// PIN-gated "Enable DashPay": one IdentityUpdate adding whichever of /// the ENCRYPTION/DECRYPTION pair the identity is missing on Platform diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift index 4f3d4afd1..4b2b33743 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift @@ -38,7 +38,7 @@ struct AddContactScreen: View { @State private var errorMessage: String? = nil /// Username of the recipient of a just-sent request — drives the /// centered success card (nil = hidden). - @State private var sentToUsername: String? = nil + @State private var sentToUsername: String? @ObservedObject private var service = SwiftDashSDKContactsService.shared @@ -334,9 +334,16 @@ struct AddContactScreen: View { try await service.sendContactRequest( to: target.identityId, usernameHint: target.fullName) - withAnimation { sentToUsername = target.fullName.withoutDashSuffix } + let username = target.fullName.withoutDashSuffix + withAnimation { sentToUsername = username } try? await Task.sleep(nanoseconds: 2_500_000_000) - withAnimation { sentToUsername = nil } + // Another send may have replaced the card in the + // meantime — only this recipient's own timer clears it. + withAnimation { + if sentToUsername == username { + sentToUsername = nil + } + } } catch SwiftDashSDKContactsService.ServiceError.authCancelled { // User backed out of the PIN prompt. } catch { diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 62615aff2..7caca649f 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -31,10 +31,14 @@ final class ContactsViewModel: ObservableObject { /// True while the main identity's persisted key set lacks the DIP-15 /// ENCRYPTION/DECRYPTION pair — without it nobody can send this - /// identity a contact request. Drives the "Enable DashPay" banner. + /// identity a contact request. Drives the "Enable DashPay" intro. @Published var needsDashPayEnable = false @Published var isEnablingDashPay = false + /// How many of the pair are missing (1 or 2 while + /// `needsDashPayEnable`); sizes the fee estimate. + private var missingDashPayKeyCount = 0 + private let service = SwiftDashSDKContactsService.shared init() { @@ -51,13 +55,16 @@ final class ContactsViewModel: ObservableObject { func refresh() { service.refresh() - needsDashPayEnable = service.mainIdentityNeedsDashPayKeys() + missingDashPayKeyCount = service.missingDashPayKeyCount() + needsDashPayEnable = missingDashPayKeyCount > 0 } - /// Estimated IdentityUpdate fee for the confirm sheet: - /// "~0.0000131 DASH (≈ THB 0.01)" in the user's local currency. + /// Estimated IdentityUpdate fee for the confirm sheet, sized to the + /// keys actually missing: "~0.000131 DASH (≈ THB 0.13)" in the user's + /// local currency. var enableDashPayEstimatedCostText: String { - let duffs = SwiftDashSDKContactsService.enableDashPayEstimatedFeeDuffs + let duffs = SwiftDashSDKContactsService.enableDashPayEstimatedFeeDuffs( + missingKeyCount: missingDashPayKeyCount) return String.localizedStringWithFormat( NSLocalizedString("~%@ DASH (≈ %@)", comment: "DashPay: estimated network fee — DASH amount, then its local-currency equivalent"), duffs.formattedDashAmountWithoutCurrencySymbol, @@ -78,6 +85,7 @@ final class ContactsViewModel: ObservableObject { defer { isEnablingDashPay = false } do { _ = try await service.enableDashPay() + missingDashPayKeyCount = 0 needsDashPayEnable = false showEnableSuccess = true } catch SwiftDashSDKContactsService.ServiceError.authCancelled { @@ -214,7 +222,9 @@ struct ContactsScreen: View { pendingFirstContactRequest = true viewModel.showEnableSuccess = false }) - .presentationDetents([.height(420)]) + // .medium can clip at large Dynamic Type sizes; the sheet + // content scrolls and .large stays reachable. + .presentationDetents([.medium, .large]) } .sheet(item: $selectedContact) { contact in ContactProfileSheet(contact: contact) @@ -245,7 +255,9 @@ struct ContactsScreen: View { showingEnableDashPay: $showingEnableDashPay) .sheet(isPresented: $showingEnableDashPay) { EnableDashPayConfirmSheet(viewModel: viewModel) - .presentationDetents([.height(470)]) + // .medium can clip at large Dynamic Type sizes; the + // sheet content scrolls and .large stays reachable. + .presentationDetents([.medium, .large]) } } else if viewModel.isEmpty { emptyState @@ -466,7 +478,9 @@ private struct DashPayIntroView: View { .padding(.horizontal, 20) .padding(.top, 20) - Button(action: { showingFAQ = true }) { + Button { + showingFAQ = true + } label: { Text(NSLocalizedString("Learn More", comment: "DashPay intro: opens the FAQ")) .font(.system(size: 15, weight: .medium)) .foregroundColor(.dash.blue) @@ -476,7 +490,9 @@ private struct DashPayIntroView: View { } } - Button(action: { showingEnableDashPay = true }) { + Button { + showingEnableDashPay = true + } label: { Text(NSLocalizedString("Enable DashPay", comment: "DashPay: add the identity keys other users need to send contact requests")) .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.dash.whiteText) @@ -548,7 +564,7 @@ private struct DashPayFAQSheet: View { Item( question: NSLocalizedString("Why do I need to enable it?", comment: "DashPay FAQ"), answer: NSLocalizedString( - "Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event.", + "Your identity needs an encryption and a decryption key so contact requests can be encrypted between you and other users. Enabling adds whichever of them are missing with a single network transaction. This is a one time event.", comment: "DashPay FAQ")), Item( question: NSLocalizedString("How private is DashPay?", comment: "DashPay FAQ"), @@ -639,6 +655,15 @@ private struct EnableDashPaySuccessSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { + // Scrollable so every action stays reachable at large Dynamic + // Type sizes. + ScrollView { + successContent + } + .background(Color.dash.primaryBackground) + } + + private var successContent: some View { VStack(spacing: 0) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 40)) @@ -662,8 +687,6 @@ private struct EnableDashPaySuccessSheet: View { .padding(.horizontal, 28) .padding(.top, 8) - Spacer(minLength: 12) - Button(action: onSendFirstRequest) { Text(NSLocalizedString("Send your first contact request", comment: "DashPay: CTA of the enable success sheet")) .font(.system(size: 16, weight: .semibold)) @@ -674,8 +697,11 @@ private struct EnableDashPaySuccessSheet: View { .cornerRadius(12) } .padding(.horizontal, 20) + .padding(.top, 20) - Button(action: { dismiss() }) { + Button { + dismiss() + } label: { Text(NSLocalizedString("Not now", comment: "DashPay: dismiss button of the enable success sheet")) .font(.system(size: 16, weight: .medium)) .foregroundColor(.dash.blue) @@ -685,7 +711,6 @@ private struct EnableDashPaySuccessSheet: View { .padding(.horizontal, 20) .padding(.bottom, 10) } - .background(Color.dash.primaryBackground) } } @@ -701,6 +726,15 @@ private struct EnableDashPayConfirmSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { + // Scrollable so every action stays reachable at large Dynamic + // Type sizes. + ScrollView { + confirmContent + } + .background(Color.dash.primaryBackground) + } + + private var confirmContent: some View { VStack(spacing: 0) { Image(systemName: "person.2.badge.key.fill") .font(.system(size: 34)) @@ -715,7 +749,7 @@ private struct EnableDashPayConfirmSheet: View { .padding(.top, 16) Text(NSLocalizedString( - "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event.", + "This adds the missing keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event.", comment: "DashPay: body of the Enable DashPay confirmation")) .font(.system(size: 14)) .foregroundColor(.dash.secondaryText) @@ -746,12 +780,10 @@ private struct EnableDashPayConfirmSheet: View { .foregroundColor(.dash.tertiaryText) .padding(.top, 6) - Spacer(minLength: 12) - - Button(action: { + Button { dismiss() viewModel.enableDashPay() - }) { + } label: { Text(NSLocalizedString("Enable", comment: "DashPay: confirm button of the Enable DashPay sheet")) .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.dash.whiteText) @@ -762,8 +794,11 @@ private struct EnableDashPayConfirmSheet: View { } .disabled(viewModel.isEnablingDashPay) .padding(.horizontal, 20) + .padding(.top, 20) - Button(action: { dismiss() }) { + Button { + dismiss() + } label: { Text(NSLocalizedString("Cancel", comment: "")) .font(.system(size: 16, weight: .medium)) .foregroundColor(.dash.blue) @@ -773,7 +808,6 @@ private struct EnableDashPayConfirmSheet: View { .padding(.horizontal, 20) .padding(.bottom, 10) } - .background(Color.dash.primaryBackground) } } diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift index 12978f429..5baade316 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift @@ -700,7 +700,9 @@ struct IdentityPublicKeysScreen: View { private var header: some View { VStack(alignment: .leading, spacing: 0) { HStack { - Button(action: { vc.popViewController(animated: true) }) { + Button { + vc.popViewController(animated: true) + } label: { Image(systemName: "chevron.left") .font(.system(size: 18, weight: .medium)) .foregroundColor(Color.dash.primaryText) @@ -770,7 +772,9 @@ struct IdentityPublicKeysScreen: View { .foregroundColor(.dash.primaryText) .textSelection(.enabled) - Button(action: { copy(key) }) { + Button { + copy(key) + } label: { HStack(spacing: 6) { Image(systemName: copiedKeyId == key.keyId ? "checkmark" : "doc.on.doc") Text(copiedKeyId == key.keyId