Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
1C2D3E4F5A6B7C8D9E0F1A2B /* TransferAmountHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C9D0E1F2A3B4C5D6E7F809 /* TransferAmountHostingController.swift */; };
1C887F21B0826303D34B8D90 /* ContactsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764BAE3F8A6B30668FEB1FA6 /* ContactsScreen.swift */; };
1CDA90D00719D6249F2761AC /* ContactItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */; };
9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */; };
1F3FF21B0295D5A326F08B74 /* OrderPreviewFeeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */; };
1FAFEA3F516E6CBDD2B49010 /* SwiftDashSDKContactsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02357928871603392E797C9A /* SwiftDashSDKContactsService.swift */; };
215F9BB0CAADF4D47D95C444 /* DWAvatarUploadClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E2D975669CB53883D56803 /* DWAvatarUploadClientTests.swift */; };
Expand Down Expand Up @@ -2544,6 +2545,7 @@
2AFF01DA243F4559003718DC /* DWDPRegistrationStatus.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWDPRegistrationStatus.m; sourceTree = "<group>"; };
2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletSendService.swift; sourceTree = "<group>"; };
2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactItem.swift; sourceTree = "<group>"; };
4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayUserLink.swift; sourceTree = "<group>"; };
2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageRecordDetailViews.swift; sourceTree = "<group>"; };
2E7EE6B2E027DC4062E9983E /* ContactProfileSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactProfileSheet.swift; sourceTree = "<group>"; };
2F134B2E67D68A97892FD760 /* WalletsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletsScreen.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6697,6 +6699,7 @@
isa = PBXGroup;
children = (
2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */,
4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */,
02357928871603392E797C9A /* SwiftDashSDKContactsService.swift */,
);
path = Contacts;
Expand Down Expand Up @@ -11116,6 +11119,7 @@
E4165429FE629AE7E9CB7E22 /* DWIdentityRegistrationController.swift in Sources */,
A15ED0B9E7AAF29C694E4E16 /* DWIdentityAuthorizer.swift in Sources */,
1CDA90D00719D6249F2761AC /* ContactItem.swift in Sources */,
9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */,
1FAFEA3F516E6CBDD2B49010 /* SwiftDashSDKContactsService.swift in Sources */,
1C887F21B0826303D34B8D90 /* ContactsScreen.swift in Sources */,
07B6BD15F525AAF000B53162 /* AddContactScreen.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// DashPayUserLink.swift
// DashWallet
//
// QR/link payload identifying a DashPay user: the Platform identity
// id (base58) plus the preferred DPNS username, encoded as
//
// dashpay://user?id=<base58-identity-id>&username=<label>
//
// Rendered as "my QR code" on the add-contact screen and parsed back
// when another user scans it. Parsing is pure and offline; it proves
// nothing — the username claim must be verified against Platform
// (DPNS lookup, identity id match) before it is shown as that user.
//

import Foundation
import SwiftDashSDK

struct DashPayUserLink: Equatable {
/// 32-byte Platform identity id.
let identityId: Data
/// Preferred DPNS label, stored without the ".dash" suffix.
let username: String

var identityIdBase58: String { identityId.toBase58String() }

/// Canonical URI form — the QR payload.
var uriString: String {
var components = URLComponents()
components.scheme = "dashpay"
components.host = "user"
components.queryItems = [
URLQueryItem(name: "id", value: identityIdBase58),
URLQueryItem(name: "username", value: username),
]
return components.string ?? "dashpay://user"
}

/// Parse a scanned string. Accepts only the canonical
/// `dashpay://user` shape carrying a valid 32-byte base58 `id` and
/// a non-empty `username` (a trailing ".dash" is tolerated and
/// stripped). Anything else — payment URIs, invitation links, bare
/// usernames — returns nil.
static func parse(_ string: String) -> DashPayUserLink? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard let components = URLComponents(string: trimmed),
Comment thread
QuantumExplorer marked this conversation as resolved.
Resolved
components.scheme?.lowercased() == "dashpay",
components.host?.lowercased() == "user"
else { return nil }

var identityId: Data?
var username: String?
for item in components.queryItems ?? [] {
switch item.name.lowercased() {
case "id": identityId = item.value.flatMap { Data.identifier(fromBase58: $0) }
case "username": username = item.value
default: break
}
}

guard let identityId, identityId.count == 32 else { return nil }
let label = (username ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.withoutDashSuffix
guard !label.isEmpty else { return nil }
return DashPayUserLink(identityId: identityId, username: label)
}
}
198 changes: 197 additions & 1 deletion DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/AddContactScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
// subtitle, elevated rounded white search field, white card result
// rows on the gray background.
//
// QR entry points ("My QR" / "Scan QR" buttons under the search
// field): scan another user's `dashpay://user` code — verified
// against Platform before the send-confirmation sheet opens — and
// show your own (`MyDashPayUserQRSheet`).
//

import SwiftDashSDK
import SwiftUI
Expand Down Expand Up @@ -46,6 +51,11 @@ struct AddContactScreen: View {
/// Username of the recipient of a just-sent request — drives the
/// centered success card (nil = hidden).
@State private var sentToUsername: String?
@State private var showScanner = false
@State private var showMyQR = false
/// A scanned user QR is being verified against Platform (DPNS
/// lookup + identity id match) — drives the blocking spinner.
@State private var isVerifyingScan = false

@ObservedObject private var service = SwiftDashSDKContactsService.shared

Expand All @@ -61,6 +71,9 @@ struct AddContactScreen: View {
height: 52)
.padding(.horizontal, 24)
.padding(.top, 22)
qrButtonsRow
.padding(.horizontal, 24)
.padding(.top, 12)
resultsList
}
}
Expand All @@ -71,6 +84,18 @@ struct AddContactScreen: View {
.foregroundColor(.dash.blue)
}
}
.fullScreenCover(isPresented: $showScanner) {
GenericQRScannerView(
onQRCodeScanned: { handleScannedCode($0) },
onCancel: { showScanner = false })
}
.sheet(isPresented: $showMyQR) {
if let link = myUserLink {
MyDashPayUserQRSheet(
link: link,
displayName: DWCurrentUserIdentityInfo.shared.displayName)
}
}
.onChange(of: query) { _, _ in
scheduleSearch()
}
Expand Down Expand Up @@ -98,7 +123,19 @@ struct AddContactScreen: View {
onAccept: { accept(target) })
}
.overlay {
if let username = sentToUsername {
if isVerifyingScan {
VStack(spacing: 12) {
SwiftUI.ProgressView()
Text(NSLocalizedString("Verifying user…", comment: "DashPay Contacts: spinner after scanning a user QR code"))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.dash.secondaryText)
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.dash.secondaryBackground)
.shadow(color: Color.dash.shadow, radius: 24, x: 0, y: 8))
} else if let username = sentToUsername {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
Expand All @@ -122,6 +159,44 @@ struct AddContactScreen: View {
}
}

/// "My QR" + "Scan QR" side by side under the search field. "My
/// QR" needs an identity with a DPNS name (the QR encodes both),
/// so before that "Scan QR" spans the full width alone.
private var qrButtonsRow: some View {
HStack(spacing: 10) {
if myUserLink != nil {
qrActionButton(
NSLocalizedString("My QR", comment: "DashPay Contacts: shows the user's own contact QR code"),
systemImage: "qrcode") {
showMyQR = true
}
}
qrActionButton(
NSLocalizedString("Scan QR", comment: ""),
systemImage: "qrcode.viewfinder") {
showScanner = true
}
}
}

private func qrActionButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .medium))
Text(title)
.font(.system(size: 15, weight: .semibold))
}
.foregroundColor(.dash.blue)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.dash.blue.opacity(0.1)))
}
.buttonStyle(.plain)
}

/// Android search screen header: icon, headline, subtitle.
private var header: some View {
VStack(spacing: 6) {
Expand Down Expand Up @@ -281,6 +356,54 @@ struct AddContactScreen: View {
}
}

// MARK: My QR / scanning

/// The current user's scannable identity payload — nil until the
/// identity is registered AND owns a DPNS name (the QR encodes
/// both, so there is nothing honest to show before that).
private var myUserLink: DashPayUserLink? {
guard let identityId = DWCurrentUserIdentityInfo.shared.identityId,
let username = DWCurrentUserIdentityInfo.shared.username
else { return nil }
return DashPayUserLink(identityId: identityId, username: username.withoutDashSuffix)
}

/// Scanned QR → parse → verify against Platform → the same
/// send-confirmation sheet a search result tap opens. Verification
/// is an exact-username DPNS search whose result must carry the
/// scanned identity id — the sheet then renders a Platform-backed
/// `DpnsSearchResult`, never the QR's own unproven claim.
private func handleScannedCode(_ value: String) {
showScanner = false
guard let link = DashPayUserLink.parse(value) else {
errorMessage = NSLocalizedString("This isn't a DashPay user QR code.", comment: "DashPay Contacts: scanned QR is a payment/invitation/foreign code")
return
}
// Mirror the scanned name into the search field so the results
// list behind the confirmation sheet shows the same user.
query = link.username
isVerifyingScan = true
Task {
Comment thread
QuantumExplorer marked this conversation as resolved.
Resolved
defer { isVerifyingScan = false }
do {
let matches = try await service.searchUsernames(prefix: link.username)
guard let match = matches.first(where: {
$0.identityId == link.identityId
&& $0.fullName.withoutDashSuffix.lowercased() == link.username.lowercased()
}) else {
errorMessage = String(
format: NSLocalizedString("%@ couldn't be verified on the Dash network. The QR code may be outdated.", comment: "DashPay Contacts: scanned username doesn't resolve to the scanned identity"),
link.username)
return
}
checkEligibilityIfNeeded(match)
previewTarget = match
} catch {
errorMessage = error.localizedDescription
}
}
}

// MARK: Search

private var trimmedQuery: String {
Expand Down Expand Up @@ -526,3 +649,76 @@ struct AddContactPreviewSheet: View {
.frame(height: 48)
}
}

// MARK: - MyDashPayUserQRSheet

/// "My QR" — the counterpart of the scan button: renders the current
/// user's `DashPayUserLink` (identity id + preferred username) as a
/// Dash-branded QR code (`QRCodeGenerator.dashStyledImage`) another
/// Dash Wallet can scan to open the send-request confirmation for
/// this user.
struct MyDashPayUserQRSheet: View {
let link: DashPayUserLink
let displayName: String?

@Environment(\.dismiss) private var dismiss
@State private var qrImage: UIImage?

var body: some View {
NavigationStack {
ZStack {
Color.dash.primaryBackground.ignoresSafeArea()
VStack(spacing: 6) {
Text(displayName ?? link.username)
.font(.system(size: 22, weight: .bold))
.foregroundColor(.dash.primaryText)
.padding(.top, 20)
if displayName != nil {
Text(link.username)
.font(.system(size: 14))
.foregroundColor(.dash.secondaryText)
}
Group {
if let qrImage {
Image(uiImage: qrImage)
.resizable()
.scaledToFit()
} else {
SwiftUI.ProgressView()
}
}
.frame(width: 240, height: 240)
.padding(20)
// The branded QR draws Dash-blue modules on a
// transparent background — keep the card white in
// dark mode so camera scanners keep their contrast.
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color.white)
.shadow(color: Color.dash.shadow, radius: 16, x: 0, y: 4))
.padding(.top, 14)
Text(NSLocalizedString("Let another Dash Wallet user scan this code to add you as a contact.", comment: "DashPay Contacts: caption under the user's own QR code"))
.font(.system(size: 14))
.foregroundColor(.dash.tertiaryText)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 32)
.padding(.top, 14)
Spacer()
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(NSLocalizedString("Close", comment: "")) { dismiss() }
.foregroundColor(.dash.blue)
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
if qrImage == nil {
qrImage = QRCodeGenerator.dashStyledImage(for: link.uriString, size: 240)
}
}
}
}
Loading
Loading