diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index d49ad2156..54a592d04 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -739,6 +739,64 @@ final class SwiftDashSDKContactsService: ObservableObject { // MARK: - Internals + /// 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 0 + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId }) + descriptor.fetchLimit = 1 + guard let identity = (try? modelContainer.mainContext.fetch(descriptor))?.first else { + return 0 + } + func hasEnabledECDSAKey(purposeRaw: String) -> Bool { + identity.publicKeys.contains { key in + key.purpose == purposeRaw + && key.keyTypeEnum == .ecdsaSecp256k1 + && !key.isDisabled + } + } + 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) 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 + /// (`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 +817,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 +840,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/AddContactScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift index 2a30b856e..4b2b33743 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? @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,16 @@ 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 } + let username = target.fullName.withoutDashSuffix + withAnimation { sentToUsername = username } + try? await Task.sleep(nanoseconds: 2_500_000_000) + // 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 935029794..7caca649f 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -29,6 +29,16 @@ 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" 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() { @@ -45,6 +55,45 @@ final class ContactsViewModel: ObservableObject { func refresh() { service.refresh() + missingDashPayKeyCount = service.missingDashPayKeyCount() + needsDashPayEnable = missingDashPayKeyCount > 0 + } + + /// 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( + missingKeyCount: missingDashPayKeyCount) + 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)) + } + + /// 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 intro clears (Platform accepted the broadcast, or + /// already had the keys) and the success sheet presents. + func enableDashPay() { + guard !isEnablingDashPay else { return } + isEnablingDashPay = true + Task { + defer { isEnablingDashPay = false } + do { + _ = try await service.enableDashPay() + missingDashPayKeyCount = 0 + needsDashPayEnable = false + showEnableSuccess = true + } catch SwiftDashSDKContactsService.ServiceError.authCancelled { + // User backed out of the PIN prompt β€” not an error state. + } catch { + errorMessage = error.localizedDescription + } + } } func syncNow() async { @@ -123,6 +172,10 @@ struct ContactsScreen: View { @StateObject private var viewModel = ContactsViewModel() @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 { @@ -131,21 +184,48 @@ 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 { - 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) { 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 + }) + // .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) } @@ -165,7 +245,21 @@ struct ContactsScreen: View { @ViewBuilder private var content: some View { - if viewModel.isEmpty { + 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) + // .medium can clip at large Dynamic Type sizes; the + // sheet content scrolls and .large stays reachable. + .presentationDetents([.medium, .large]) + } + } else if viewModel.isEmpty { emptyState } else { list @@ -325,6 +419,398 @@ 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 payments", comment: "DashPay intro: feature title"), + 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"), + 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("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) + + Button { + showingFAQ = true + } label: { + 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 { + 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) + .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 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] { + [ + 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 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"), + 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, 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"), + 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( + "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( + "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")), + ] + } + + 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: - 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 { + // 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)) + .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) + + 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) + .padding(.top, 20) + + 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) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + } +} + +// 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 { + // 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)) + .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 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) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("Estimated network fee", comment: "DashPay: fee line of the Enable DashPay confirmation")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + Text(viewModel.enableDashPayEstimatedCostText) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .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) + + 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) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .disabled(viewModel.isEnablingDashPay) + .padding(.horizontal, 20) + .padding(.top, 20) + + Button { + dismiss() + } label: { + 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) + } + } +} + // MARK: - Rows (Android dashpay_contact_row: 70pt, avatar 36, name 17sb) struct ContactRow: View { diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift index c526f1da6..5baade316 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,181 @@ 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 { + vc.popViewController(animated: true) + } label: { + 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 { + copy(key) + } label: { + 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..4a70191d3 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"; @@ -324,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"; @@ -502,6 +508,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"; @@ -629,12 +641,42 @@ /* Masternodes */ "Collateral" = "Collateral"; +/* DashPay intro: feature title */ +"Coming soon" = "Coming soon"; + /* Shielded transfer status */ "Completed" = "Completed"; /* 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"; + +/* 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."; + +/* 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, 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."; + /* Raw transaction inspector */ "Copy Raw Transaction" = "Copy Raw Transaction"; @@ -976,9 +1018,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"; @@ -1031,6 +1082,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 +1097,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"; @@ -1135,12 +1192,21 @@ /* 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"; /* 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."; @@ -1201,6 +1267,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"; @@ -1553,6 +1622,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"; @@ -1859,6 +1931,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"; @@ -1887,6 +1965,9 @@ /* Maya */ "Learn more" = "Learn more"; +/* DashPay intro: opens the FAQ */ +"Learn More" = "Learn More"; + /* Info Screen */ "Learn More..." = "Learn More..."; @@ -2185,6 +2266,72 @@ /* 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."; + +/* 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 payments" = "Private payments"; + +/* 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"; + +/* 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 */ +"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 */ +"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."; + +/* 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?"; + +/* DashPay FAQ */ +"What is DashPay?" = "What is DashPay?"; + +/* 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?"; + +/* 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"; + +/* 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."; @@ -4723,6 +4870,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.";