From 42c60d01e4bfab0df9bf60184857617601d58709 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:29:00 +0300 Subject: [PATCH 1/5] feat(dashconnect): port connections UI onto the SDK branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the DashConnect connections flow across from feat/dash-connect, which branched before the Swift SDK landed and so cannot host the real Platform implementation. Only the feature itself is carried over — the 273 commits that branch is ahead by are master merges and other features already present here. The screen is split into Components/ rather than the original single 461-line file: list, row, status badge, the two empty states, the scan button and the approve-sheet presentation modifier. Wired into the project as two PBXFileSystemSynchronizedRootGroups (Models and UI), so files added under DashConnect/ are picked up without per-file bookkeeping. DashUIKit moves from master to fix/textfield-prompt-type: the components need DashIcon and SwitchView, and current DashUIKit master does not yet carry the TextField-prompt compile fix (dashpay/DashUIKit#9). Still mock-backed. The Android side (dashpay/dash-wallet feat/dash-connect) has the real protocol — dash-key:/dash-st: URIs, the loginKeyResponse contract, and the key-exchange crypto — which this branch can now implement against the SDK. --- DashWallet.xcodeproj/project.pbxproj | 30 ++- .../xcshareddata/swiftpm/Package.resolved | 18 +- .../Contents.json | 10 + .../icon.svg | 4 + .../dashconnect.empty.imageset/Contents.json | 10 + .../dashconnect.empty.imageset/icon.svg | 9 + .../dashconnect.qr.imageset/Contents.json | 10 + .../dashconnect.qr.imageset/icon.svg | 10 + .../Contents.json | 10 + .../icon.svg | 5 + .../menu.connections.imageset/Contents.json | 10 + .../menu.connections.imageset/icon.svg | 8 + .../DashConnect/DashConnectDataSource.swift | 152 ++++++++++++ .../DashConnect/DashConnectModels.swift | 45 ++++ .../DashConnect/ApproveConnectionSheet.swift | 217 ++++++++++++++++++ .../Components/ApproveSheetPresentation.swift | 52 +++++ .../Components/ConnectionRow.swift | 119 ++++++++++ .../Components/ConnectionStatusBadge.swift | 80 +++++++ .../Components/ConnectionsEmptyState.swift | 74 ++++++ .../Components/ConnectionsList.swift | 65 ++++++ .../ConnectionsUnavailableState.swift | 66 ++++++ .../DashConnect/Components/ScanQRButton.swift | 53 +++++ .../Components/ScanToCompleteBanner.swift | 62 +++++ .../UI/DashConnect/ConnectionsScreen.swift | 197 ++++++++++++++++ .../UI/DashConnect/ConnectionsViewModel.swift | 102 ++++++++ .../UI/Menu/Tools/ToolsMenuScreen.swift | 9 + .../UI/Menu/Tools/ToolsMenuViewModel.swift | 8 + 27 files changed, 1425 insertions(+), 10 deletions(-) create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/icon.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/icon.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg create mode 100644 DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift create mode 100644 DashWallet/Sources/Models/DashConnect/DashConnectModels.swift create mode 100644 DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift create mode 100644 DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift create mode 100644 DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift create mode 100644 DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 1ee132063..0aea6ddfa 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -3665,6 +3665,28 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 51DC0001300000000000DC01 /* DashConnect */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = DashConnect; + sourceTree = ""; + }; + 51DC0002300000000000DC02 /* DashConnect */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = DashConnect; + sourceTree = ""; + }; 51DFA7FB2FA8EAF2001EDB3A /* Components */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -4508,6 +4530,7 @@ 2A44313C22CF631E009BAF7F /* UI */ = { isa = PBXGroup; children = ( + 51DC0001300000000000DC01 /* DashConnect */, 7566F4882BB6CAD6005238D2 /* SwiftUI Components */, C943B34F2A40A54500AF23C5 /* DashPay */, 4751137228DAF27300223B77 /* Assembly */, @@ -4542,6 +4565,7 @@ 2A44313D22CF632F009BAF7F /* Models */ = { isa = PBXGroup; children = ( + 51DC0002300000000000DC02 /* DashConnect */, AA0002192BA0F58E00A1B309 /* Maya */, AA0010002CA0B10001A0B100 /* SwapKit */, AA0020012FA0000000000001 /* Swap */, @@ -8659,6 +8683,8 @@ ); fileSystemSynchronizedGroups = ( 51DFA7FB2FA8EAF2001EDB3A /* Components */, + 51DC0001300000000000DC01 /* DashConnect */, + 51DC0002300000000000DC02 /* DashConnect */, ); name = dashwallet; packageProductDependencies = ( @@ -8763,6 +8789,8 @@ ); fileSystemSynchronizedGroups = ( 51DFA7FB2FA8EAF2001EDB3A /* Components */, + 51DC0001300000000000DC01 /* DashConnect */, + 51DC0002300000000000DC02 /* DashConnect */, ); name = dashpay; packageProductDependencies = ( @@ -13419,7 +13447,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dashpay/DashUIKit"; requirement = { - branch = master; + branch = "fix/textfield-prompt-type"; kind = branch; }; }; diff --git a/DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved index c0f6c60fc..1c02cc8eb 100644 --- a/DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DashWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,15 @@ { - "originHash" : "c249ed8d75daf51cf5f1467a120fdeaf4cb1d396e8e7721de5bd30aa8b713ba4", - "pins" : [ + "originHash": "c249ed8d75daf51cf5f1467a120fdeaf4cb1d396e8e7721de5bd30aa8b713ba4", + "pins": [ { - "identity" : "dashuikit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/dashpay/DashUIKit", - "state" : { - "branch" : "master", - "revision" : "046f087514e12f01087ba20efd5e73c682172ebf" + "identity": "dashuikit", + "kind": "remoteSourceControl", + "location": "https://github.com/dashpay/DashUIKit", + "state": { + "branch": "fix/textfield-prompt-type", + "revision": "449c6ce3f239cb13b45777e4dde95e5d9ff18f99" } } ], - "version" : 3 + "version": 3 } diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/icon.svg new file mode 100644 index 000000000..1c77fcaa7 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.check.circle.imageset/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/icon.svg new file mode 100644 index 000000000..427d272eb --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg new file mode 100644 index 000000000..532118952 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.qr.imageset/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg new file mode 100644 index 000000000..ec20fc517 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.xmark.circle.imageset/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json new file mode 100644 index 000000000..5ff20cb2d --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "icon.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg new file mode 100644 index 000000000..3163b039e --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu.connections.imageset/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift new file mode 100644 index 000000000..e99325f51 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -0,0 +1,152 @@ +// +// DashConnectDataSource.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +protocol DashConnectDataSource { + var connections: AnyPublisher<[DAppConnection], Never> { get } + + func parseQR(_ content: String) async throws -> ConnectionRequest + func approve(_ request: ConnectionRequest) async throws -> DAppConnection + func disconnect(id: String) async + func remove(id: String) async +} + +enum DashConnectMockError: LocalizedError { + case parseFailed + case approveFailed + + var errorDescription: String? { + switch self { + case .parseFailed: + return "Invalid QR payload" + case .approveFailed: + return "Approval failed" + } + } +} + +final class MockDashConnectDataSource: DashConnectDataSource { + static let sampleRequest = ConnectionRequest( + appLabel: "Yappr", + appUrl: "yap.pr", + appContractId: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + walletUsername: "john.doe", + walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8" + ) + + static func sample(_ status: ConnectionStatus) -> DAppConnection { + DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yappr.io", + status: status, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + } + + #if DEBUG + static func previewMixed() -> MockDashConnectDataSource { + MockDashConnectDataSource(initial: [ + sample(.approved), + DAppConnection( + id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", + name: "DashGet", + url: "dashget.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_218_700) + ), + DAppConnection( + id: "AaC695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg09Z", + name: "PayKit", + url: "paykit.dev", + status: .disconnected, + updatedAt: Date(timeIntervalSince1970: 1_773_305_100) + ), + ]) + } + #endif + + var shouldFailNextParse = false + var shouldFailNextApprove = false + + private let subject: CurrentValueSubject<[DAppConnection], Never> + + init(initial: [DAppConnection] = []) { + self.subject = CurrentValueSubject(initial) + } + + var connections: AnyPublisher<[DAppConnection], Never> { + subject.eraseToAnyPublisher() + } + + func parseQR(_ content: String) async throws -> ConnectionRequest { + _ = content + try await Task.sleep(nanoseconds: 400_000_000) + + if shouldFailNextParse { + shouldFailNextParse = false + throw DashConnectMockError.parseFailed + } + + return Self.sampleRequest + } + + func approve(_ request: ConnectionRequest) async throws -> DAppConnection { + try await Task.sleep(nanoseconds: 400_000_000) + + if shouldFailNextApprove { + shouldFailNextApprove = false + throw DashConnectMockError.approveFailed + } + + let connection = DAppConnection( + id: request.appContractId, + name: request.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : request.appLabel, + url: request.appUrl, + status: .approved, + updatedAt: Date() + ) + + var current = subject.value.filter { $0.id != connection.id } + current.append(connection) + subject.send(current.sorted(by: { $0.updatedAt > $1.updatedAt })) + return connection + } + + func disconnect(id: String) async { + subject.send( + subject.value.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .disconnected, + updatedAt: Date() + ) + } + ) + } + + func remove(id: String) async { + subject.send(subject.value.filter { $0.id != id }) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift new file mode 100644 index 000000000..ae0309b27 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -0,0 +1,45 @@ +// +// DashConnectModels.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Lifecycle of an app connection. +enum ConnectionStatus: String, Codable, CaseIterable { + case approved + case active + case disconnected +} + +/// A connected app identified by its stable contract id. +struct DAppConnection: Identifiable, Equatable, Hashable { + let id: String + let name: String + let url: String + let status: ConnectionStatus + let updatedAt: Date +} + +/// A pending request derived from a scanned login QR code. +struct ConnectionRequest: Equatable { + let appLabel: String + let appUrl: String + let appContractId: String + let walletUsername: String + let walletIdentityId: String +} diff --git a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift new file mode 100644 index 000000000..bdcc27205 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift @@ -0,0 +1,217 @@ +// +// ApproveConnectionSheet.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct ApproveConnectionSheet: View { + let request: ConnectionRequest + var isLoading: Bool = false + var onApprove: () -> Void + var onDeny: () -> Void + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color.gray300Alpha50) + .frame(width: 36, height: 5) + .padding(.top, 6) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text( + String( + format: NSLocalizedString("Approve connection to %@?", comment: "DashConnect"), + resolvedAppLabel + ) + ) + .font(.title2) + .foregroundColor(.primaryText) + + Text(resolvedSubtitle) + .font(.subhead) + .foregroundColor(.secondaryText) + } + + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will be able to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("See your username", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("Verify your identity", comment: "DashConnect") + ) + } + + VStack(spacing: 4) { + DetailRow( + label: NSLocalizedString("Username", comment: "DashConnect"), + value: request.walletUsername + ) + DetailRow( + label: NSLocalizedString("Identity", comment: "DashConnect"), + value: truncateMiddle(request.walletIdentityId) + ) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) + ) + + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will NOT have access to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Your private keys", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Withdraw funds", comment: "DashConnect") + ) + } + + Spacer(minLength: 0) + + VStack(spacing: 8) { + DashButton( + text: NSLocalizedString("Approve", comment: "DashConnect"), + style: .filledBlue, + size: .large, + stretch: true, + isEnabled: !isLoading, + isLoading: isLoading, + action: onApprove + ) + + DashButton( + text: NSLocalizedString("Deny", comment: "DashConnect"), + style: .tintedBlue, + size: .large, + stretch: true, + isEnabled: !isLoading, + action: onDeny + ) + } + } + // Figma uses a much wider horizontal inset here; 20pt matches the + // phone sheets already used in this iOS app and avoids over-compressing the content. + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .background(Color.primaryBackground) + } + + private var resolvedAppLabel: String { + let trimmed = request.appLabel.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : trimmed + } + + private var resolvedSubtitle: String { + let candidates = [ + request.appUrl.trimmingCharacters(in: .whitespacesAndNewlines), + request.appLabel.trimmingCharacters(in: .whitespacesAndNewlines), + NSLocalizedString("Unknown app", comment: "DashConnect"), + ] + return candidates.first(where: { !$0.isEmpty }) ?? NSLocalizedString("Unknown app", comment: "DashConnect") + } + + /// Truncates a long identifier in the middle, keeping `prefix` leading and + /// `suffix` trailing characters (e.g. "5DbLwAx…7zUo8"). + fileprivate func truncateMiddle(_ value: String, prefix: Int = 7, suffix: Int = 5) -> String { + guard value.count > prefix + suffix + 1 else { return value } + return "\(value.prefix(prefix))…\(value.suffix(suffix))" + } +} + +private struct PermissionRow: View { + let icon: IconName + let text: String + + var body: some View { + HStack(spacing: 6) { + Icon(name: icon) + .frame(width: 18, height: 18) + + Text(text) + .font(.subhead) + .foregroundColor(.primaryText) + } + } +} + +private struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack(spacing: 8) { + Text(label) + .font(.subhead) + .foregroundColor(.primaryText) + + Spacer(minLength: 12) + + Text(value) + .font(.subhead) + .foregroundColor(.primaryText) + .multilineTextAlignment(.trailing) + } + } +} + +#if DEBUG +private let dashConnectTruncateMiddleCheck: Bool = { + let sheet = ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + onApprove: {}, + onDeny: {} + ) + assert( + sheet.truncateMiddle("5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8") == "5DbLwAx…zUo8" + ) + return true +}() +#endif + +#Preview("Approve") { + ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + onApprove: {}, + onDeny: {} + ) +} + +#Preview("Approve Loading") { + ApproveConnectionSheet( + request: MockDashConnectDataSource.sampleRequest, + isLoading: true, + onApprove: {}, + onDeny: {} + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift new file mode 100644 index 000000000..49b2d9931 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ApproveSheetPresentation.swift @@ -0,0 +1,52 @@ +// +// ApproveSheetPresentation.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// Applies the approve-connection sheet's presentation options, degrading on +/// older systems. Keeping it in a modifier means the sheet content is written +/// once rather than repeated per availability branch. +struct ApproveSheetPresentation: ViewModifier { + let isLoading: Bool + + private let detentHeight: CGFloat = 620 + private let cornerRadius: CGFloat = 32 + + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content + .presentationDetents([.height(detentHeight)]) + .presentationCornerRadius(cornerRadius) + .interactiveDismissDisabled(isLoading) + } else if #available(iOS 16.0, *) { + content + .presentationDetents([.height(detentHeight)]) + .interactiveDismissDisabled(isLoading) + } else { + content + } + } +} + +extension View { + /// Presents this view as the approve-connection sheet body. + func approveSheetPresentation(isLoading: Bool) -> some View { + modifier(ApproveSheetPresentation(isLoading: isLoading)) + } +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift new file mode 100644 index 000000000..00bc5c6f1 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift @@ -0,0 +1,119 @@ +// +// ConnectionRow.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// One connected app: its name and url, the current status, and — once the +/// connection is live — the switch that disconnects it. +/// +/// An approved connection is not usable yet, so the row carries the prompt to +/// finish the login underneath itself rather than leaving the list to know +/// which rows need it. +struct ConnectionRow: View { + let connection: DAppConnection + let onPrimaryAction: () -> Void + let onMockScan: () -> Void + let onDisconnect: () -> Void + + private enum Layout { + /// `SwitchView` is a fixed 64×28 from the design system and sets that + /// width internally, so an outer `.frame` cannot shrink it — it would + /// only change the space reserved while the control kept overflowing. + /// Scaling is the only way to make it smaller from the outside; the + /// design ratio is preserved. + static let switchScale: CGFloat = 0.75 + static let switchWidth: CGFloat = 64 * switchScale + static let switchHeight: CGFloat = 28 * switchScale + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 20) { + identity + + ConnectionStatusBadge(status: connection.status, updatedAt: connection.updatedAt) + .frame(maxWidth: .infinity, alignment: .topTrailing) + + if connection.status == .active { + disconnectSwitch + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + + if connection.status == .approved { + ScanToCompleteBanner(onScanQR: onPrimaryAction, onMockScan: onMockScan) + } + } + } + + private var identity: some View { + VStack(alignment: .leading, spacing: 2) { + Text(connection.name) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + + if !connection.url.isEmpty { + Text(connection.url) + .dashFont(.footnote) + .foregroundStyle(Color.dash.tertiaryText) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + /// Always reads as on: the row only shows it for an active connection, and + /// switching it off is what disconnects. The binding therefore ignores the + /// on direction, which cannot be reached from here. + private var disconnectSwitch: some View { + SwitchView(isOn: Binding( + get: { true }, + set: { isOn in + if !isOn { + onDisconnect() + } + } + )) + .scaleEffect(Layout.switchScale) + .frame(width: Layout.switchWidth, height: Layout.switchHeight) + } +} + +#Preview { + VStack(spacing: 2) { + ForEach(ConnectionStatus.allCases, id: \.self) { status in + ConnectionRow( + connection: DAppConnection( + id: status.rawValue, + name: "Example App", + url: "https://example.org", + status: status, + updatedAt: Date() + ), + onPrimaryAction: {}, + onMockScan: {}, + onDisconnect: {} + ) + } + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift new file mode 100644 index 000000000..78dd1776e --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift @@ -0,0 +1,80 @@ +// +// ConnectionStatusBadge.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The status column of a connection row: a coloured dot with the status name, +/// above the moment that status was last reached. +struct ConnectionStatusBadge: View { + let status: ConnectionStatus + let updatedAt: Date + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "d MMM yyyy, H:mm" + return formatter + }() + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + + Text(title) + .dashFont(.footnote) + .foregroundColor(Color.dash.primaryText) + } + + Text(Self.dateFormatter.string(from: updatedAt)) + .dashFont(.footnote) + .foregroundColor(Color.dash.tertiaryText) + } + } + + /// Green only once the connection is live; every other state is still in + /// progress or ended, and reads as neutral blue. + private var dotColor: Color { + status == .active ? Color.dash.green : Color.dash.blue + } + + private var title: String { + switch status { + case .approved: + return NSLocalizedString("Approved", comment: "DashConnect") + case .active: + return NSLocalizedString("Active", comment: "DashConnect") + case .disconnected: + return NSLocalizedString("Disconnected", comment: "DashConnect") + } + } +} + +#Preview { + VStack(alignment: .trailing, spacing: 16) { + ForEach(ConnectionStatus.allCases, id: \.self) { status in + ConnectionStatusBadge(status: status, updatedAt: Date()) + } + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift new file mode 100644 index 000000000..e9ee3fa3a --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsEmptyState.swift @@ -0,0 +1,74 @@ +// +// ConnectionsEmptyState.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Shown when the feature is available but nothing is connected yet. Explains +/// what a connection is for, then offers the only way to make one. +struct ConnectionsEmptyState: View { + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + VStack { + Spacer() + + VStack(spacing: 40) { + VStack(spacing: 20) { + HStack(spacing: 0) { + Image("dashconnect-empty") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + } + .frame(width: 80, height: 80) + + VStack(spacing: 4) { + Text(NSLocalizedString("No apps connected yet", comment: "DashConnect")) + .dashFont(.title3Medium) + .foregroundColor(Color.dash.primaryText) + + Text( + NSLocalizedString( + "Connect your DashPay username to apps on the Dash network to sign in without passwords or accounts", + comment: "DashConnect" + ) + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + } + .multilineTextAlignment(.center) + } + + ScanQRButton(onScanQR: onScanQR, onMockScan: onMockScan) + .padding(.horizontal, 60) + } + .padding(.horizontal, 60) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ConnectionsEmptyState(onScanQR: {}, onMockScan: {}) + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift new file mode 100644 index 000000000..2fbfea899 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift @@ -0,0 +1,65 @@ +// +// ConnectionsList.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The populated state: every connection stacked in one card. Rows carry their +/// own status-specific content, so this only owns the card chrome and scroll. +struct ConnectionsList: View { + let connections: [DAppConnection] + let onScanQR: () -> Void + let onMockScan: () -> Void + let onDisconnect: (DAppConnection) -> Void + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 2) { + ForEach(connections) { connection in + ConnectionRow( + connection: connection, + onPrimaryAction: onScanQR, + onMockScan: onMockScan, + onDisconnect: { onDisconnect(connection) } + ) + } + } + .modifier(MenuViewModifier()) + } + } +} + +#Preview { + ConnectionsList( + connections: ConnectionStatus.allCases.map { status in + DAppConnection( + id: status.rawValue, + name: "Example App", + url: "https://example.org", + status: status, + updatedAt: Date() + ) + }, + onScanQR: {}, + onMockScan: {}, + onDisconnect: { _ in } + ) + .padding(20) + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift new file mode 100644 index 000000000..f1d18473a --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsUnavailableState.swift @@ -0,0 +1,66 @@ +// +// ConnectionsUnavailableState.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Shown when DashConnect is not offered on the current network. There is no +/// call to action — the user cannot opt in, only switch networks. +struct ConnectionsUnavailableState: View { + var body: some View { + VStack { + Spacer() + + VStack(spacing: 16) { + HStack(spacing: 0) { + Image("dashconnect-empty") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + } + .frame(width: 80, height: 80) + + VStack(spacing: 4) { + Text(NSLocalizedString("Available on testnet only", comment: "DashConnect")) + .dashFont(.title3Medium) + .foregroundColor(Color.dash.primaryText) + + Text( + NSLocalizedString( + "DashConnect login is not yet available on mainnet", + comment: "DashConnect" + ) + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + } + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + ConnectionsUnavailableState() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift b/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift new file mode 100644 index 000000000..208fc8834 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ScanQRButton.swift @@ -0,0 +1,53 @@ +// +// ScanQRButton.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The single "Scan QR" call to action, so the button reads the same wherever +/// it appears and the debug shortcut lives in one place. +/// +/// In debug builds a long press feeds a mock payload, which lets the connect +/// flow be exercised without a second device showing a real login QR. +struct ScanQRButton: View { + var size: DashUIKit.DashButtonSize = .large + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + DashUIKit.DashButton( + text: NSLocalizedString("Scan QR", comment: "DashConnect"), + size: size, + style: .filledBlue, + action: onScanQR + ) + #if DEBUG + .onLongPressGesture(perform: onMockScan) + #endif + } +} + +#Preview { + VStack(spacing: 20) { + ScanQRButton(onScanQR: {}, onMockScan: {}) + ScanQRButton(size: .small, onScanQR: {}, onMockScan: {}) + } + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift new file mode 100644 index 000000000..46a3a0d75 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift @@ -0,0 +1,62 @@ +// +// ScanToCompleteBanner.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// Prompt attached to an approved connection: approval alone does not log the +/// user in, the app still has to scan the login QR to finish. +struct ScanToCompleteBanner: View { + let onScanQR: () -> Void + let onMockScan: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + HStack(alignment: .center, spacing: 0) { + DashIcon.SystemMessage.infoRectSmall.image + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .frame(width: 30, height: 30, alignment: .center) + + HStack(alignment: .top, spacing: 20) { + Text( + NSLocalizedString("Scan the QR code to complete the login process", comment: "DashConnect") + ) + .dashFont(.subhead) + .foregroundColor(Color.dash.primaryText) + .frame(maxWidth: .infinity, alignment: .leading) + + ScanQRButton(size: .small, onScanQR: onScanQR, onMockScan: onMockScan) + } + .padding(.vertical, 5) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.dash.blueAlpha5) + .cornerRadius(20) + } +} + +#Preview { + ScanToCompleteBanner(onScanQR: {}, onMockScan: {}) + .padding() + .background(Color.primaryBackground) +} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift new file mode 100644 index 000000000..2fa1a15fd --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -0,0 +1,197 @@ +// +// ConnectionsScreen.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import UIKit + +/// Lists the apps connected to this wallet. The states it can be in live in +/// `Components/`; this type owns navigation, the QR scanner and the approval +/// sheet. +struct ConnectionsScreen: View { + private let vc: UINavigationController + + @StateObject private var viewModel: ConnectionsViewModel + + init(vc: UINavigationController, viewModel: ConnectionsViewModel? = nil) { + self.vc = vc + _viewModel = StateObject(wrappedValue: viewModel ?? ConnectionsViewModel()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavBarBack { + vc.popViewController(animated: true) + } + + TopIntro(title: NSLocalizedString("Connections", comment: "DashConnect")) + .padding(.leading, 20) + .padding(.trailing, 60) + .padding(.top, 10) + .padding(.bottom, 20) + + content + } + .background(Color.primaryBackground) + .navigationBarHidden(true) + .alert(NSLocalizedString("Error", comment: ""), isPresented: errorIsPresented) { + Button(NSLocalizedString("OK", comment: "")) { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + .sheet(isPresented: isApproveSheetPresented) { + if let request = viewModel.pendingRequest { + approveSheet(for: request) + } + } + } + + // MARK: - States + + @ViewBuilder private var content: some View { + if viewModel.featureUnavailable { + ConnectionsUnavailableState() + } else if viewModel.connections.isEmpty { + ConnectionsEmptyState(onScanQR: showScanner, onMockScan: mockScan) + } else { + ConnectionsList( + connections: viewModel.connections, + onScanQR: showScanner, + onMockScan: mockScan, + onDisconnect: viewModel.disconnect + ) + } + } + + private func approveSheet(for request: ConnectionRequest) -> some View { + ApproveConnectionSheet( + request: request, + isLoading: viewModel.isApproving, + onApprove: { viewModel.approvePendingRequest() }, + onDeny: { viewModel.denyPendingRequest() } + ) + .approveSheetPresentation(isLoading: viewModel.isApproving) + } + + // MARK: - Presentation bindings + + private var isApproveSheetPresented: Binding { + Binding( + get: { viewModel.pendingRequest != nil }, + set: { isPresented in + if !isPresented && !viewModel.isApproving { + viewModel.denyPendingRequest() + } + } + ) + } + + private var errorIsPresented: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { isPresented in + if !isPresented { + viewModel.errorMessage = nil + } + } + ) + } + + // MARK: - Actions + + private func showScanner() { + let scanner = GenericQRScannerController() + + // The mock UI flow accepts any scanned payload for now; MO-945 will + // replace this with real login-QR parsing. + scanner.onQRCodeScanned = { value in + vc.dismiss(animated: true) { + viewModel.onQRScanned(value) + } + } + scanner.onCancel = { + vc.dismiss(animated: true) + } + + vc.present(scanner, animated: true) + } + + private func mockScan() { + viewModel.onQRScanned("dash-key:mock") + } +} + +// MARK: - Previews + +#Preview("Unavailable") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(), + featureUnavailable: true + ) + ) +} + +#Preview("Empty") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(), + featureUnavailable: false + ) + ) +} + +#Preview("Approved") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(initial: [.init( + id: MockDashConnectDataSource.sample(.approved).id, + name: MockDashConnectDataSource.sample(.approved).name, + url: MockDashConnectDataSource.sample(.approved).url, + status: .approved, + updatedAt: MockDashConnectDataSource.sample(.approved).updatedAt + )]), + featureUnavailable: false + ) + ) +} + +#Preview("Active") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(initial: [MockDashConnectDataSource.sample(.active)]), + featureUnavailable: false + ) + ) +} + +#Preview("Disconnected") { + ConnectionsScreen( + vc: UINavigationController(), + viewModel: ConnectionsViewModel( + dataSource: MockDashConnectDataSource(initial: [MockDashConnectDataSource.sample(.disconnected)]), + featureUnavailable: false + ) + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift new file mode 100644 index 000000000..c299a4541 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -0,0 +1,102 @@ +// +// ConnectionsViewModel.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +@MainActor +final class ConnectionsViewModel: ObservableObject { + @Published private(set) var connections: [DAppConnection] = [] + @Published private(set) var featureUnavailable: Bool + @Published var pendingRequest: ConnectionRequest? + @Published var isApproving = false + @Published var errorMessage: String? + + private let dataSource: any DashConnectDataSource + private var cancellables = Set() + + init( + dataSource: any DashConnectDataSource = MockDashConnectDataSource(), + featureUnavailable: Bool? = nil + ) { + self.dataSource = dataSource + self.featureUnavailable = featureUnavailable ?? ( + DWEnvironment.sharedInstance().currentChain.chainType.tag != ChainType_TestNet + ) + + dataSource.connections + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.connections = $0.sorted(by: { $0.updatedAt > $1.updatedAt }) + } + .store(in: &cancellables) + } + + func onQRScanned(_ content: String) { + guard !featureUnavailable else { return } + + Task { + do { + pendingRequest = try await dataSource.parseQR(content) + } catch { + errorMessage = String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) + } + } + } + + func approvePendingRequest() { + guard let pendingRequest, !isApproving else { return } + + isApproving = true + + Task { + defer { isApproving = false } + + do { + _ = try await dataSource.approve(pendingRequest) + self.pendingRequest = nil + } catch { + errorMessage = String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) + } + } + } + + func denyPendingRequest() { + guard !isApproving else { return } + pendingRequest = nil + } + + func disconnect(_ connection: DAppConnection) { + Task { + await dataSource.disconnect(id: connection.id) + } + } + + func removeConnection(_ connection: DAppConnection) { + Task { + await dataSource.remove(id: connection.id) + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift index 94ccfebe7..74600e292 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuScreen.swift @@ -201,6 +201,8 @@ struct ToolsMenuScreen: View { showMasternodes() case .csvExport: showCSVExportSheet = true + case .connections: + showConnections() case .zenLedger: showZenLedgerSheet = true case .storageExplorer: @@ -296,6 +298,13 @@ struct ToolsMenuScreen: View { hosting.hidesBottomBarWhenPushed = true vc.pushViewController(hosting, animated: true) } + + private func showConnections() { + let screen = ConnectionsScreen(vc: vc) + let controller = UIHostingController(rootView: screen) + controller.hidesBottomBarWhenPushed = true + vc.pushViewController(controller, animated: true) + } private func handleCSVExport() { Task { diff --git a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift index e4a9c2864..edaf391bd 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift @@ -24,6 +24,7 @@ enum ToolsMenuNavigationDestination { case masternodeKeys case masternodes case csvExport + case connections case zenLedger case storageExplorer } @@ -131,6 +132,13 @@ class ToolsMenuViewModel: ObservableObject { self?.navigationDestination = .storageExplorer } ), + MenuItemModel( + title: NSLocalizedString("Connections", comment: "DashConnect"), + icon: .custom("menu.connections", maxHeight: 30), + action: { [weak self] in + self?.navigationDestination = .connections + } + ), MenuItemModel( title: NSLocalizedString("ZenLedger", comment: ""), subtitle: NSLocalizedString("Simplify your crypto taxes", comment: ""), From b95abb13545e32aa6984ffcfdb5a521497a44d7f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:29:28 +0300 Subject: [PATCH 2/5] refactor: reference DashUIKit icons through DashIcon and apply dashFont MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces raw asset-name strings with the DashIcon enums the library now exposes, so a renamed asset becomes a compile error instead of a blank image. Five references were already broken by DashUIKit's catalog normalization and were failing silently: CrowdNodeBalanceReminderBanner warning_triangle CoinbaseMetadataProvider transaction-coinbase.received (x2) RefundAddressView info-rect OrderPreviewView stopwatch Plus SwapPortalScaffold's menu-receive.disabled / menu-send.disabled, which degraded quietly: its disabledMenuIcon falls back to the enabled icon when the asset is missing, so disabled Buy/Sell rows rendered as enabled. Targets that take the app's own IconName keep .custom(...) and source the name from DashIcon.assetName; the rest use .source or .image directly. Also converts .font(Font.dash.X) to .dashFont(.X) so the design line height is applied with the font. TextField prompts are deliberately left on .font — a prompt must stay a Text, and a line height cannot apply to Text. --- .../CrowdNodeBalanceReminderBanner.swift | 6 +++--- .../CrowdNodeBalanceReminderSheet.swift | 6 +++--- .../Tx Metadata/CoinbaseMetadataProvider.swift | 8 ++++---- .../Tx Metadata/GiftCardMetadataProvider.swift | 8 ++++---- .../Tx Metadata/SwapOrderMetadataProvider.swift | 4 ++-- DashWallet/Sources/UI/Home/Views/HomeView.swift | 2 +- .../UI/Menu/Settings/About/AboutDashView.swift | 6 +++--- .../Buy/EnterAmount/BuyEnterAmountView.swift | 6 +++--- .../UI/Swap/Buy/Receive/BuyReceiveView.swift | 8 ++++---- .../Buy/RefundAddress/RefundAddressView.swift | 2 +- .../UI/Swap/Convert/SwapConvertView.swift | 6 +++--- .../Components/OrderPreviewFeeRow.swift | 4 ++-- .../Components/OrderPreviewTableRow.swift | 4 ++-- .../Components/SwapFeeInfoSheet.swift | 4 ++-- .../UI/Swap/OrderPreview/OrderPreviewView.swift | 16 ++++++++-------- .../UI/Swap/SelectCoin/SelectCoinView.swift | 4 ++-- .../Sources/UI/Swap/SwapPortalScaffold.swift | 12 ++++++------ .../SwapTransactionFailureView.swift | 6 +++--- .../SwapTransactionPendingView.swift | 2 +- .../Sources/UI/SwapKit/SwapKitPortalView.swift | 3 ++- 20 files changed, 59 insertions(+), 58 deletions(-) diff --git a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift index 5f8e01dd6..ad7ac0da2 100644 --- a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift +++ b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderBanner.swift @@ -23,7 +23,7 @@ struct CrowdNodeBalanceReminderBanner: View { var body: some View { HStack(alignment: .top, spacing: 10) { - Image(dash: .custom("warning_triangle", bundle: .dashUIKit)) + DashIcon.SystemMessage.warningTriangle.image .resizable() .scaledToFit() .frame(width: 30, height: 30) @@ -31,11 +31,11 @@ struct CrowdNodeBalanceReminderBanner: View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 1) { Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.primaryText) Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift index f6568dbcf..cdd9f772d 100644 --- a/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift +++ b/DashWallet/Sources/UI/CrowdNode/BalanceReminder/CrowdNodeBalanceReminderSheet.swift @@ -24,7 +24,7 @@ struct CrowdNodeBalanceReminderSheet: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - Image(dash: .custom("crowdnode.warning", bundle: .dashUIKit)) + DashIcon.Illustrations.crowdnodeWarning.image .resizable() .scaledToFit() .frame(width: 90, height: 90) @@ -35,12 +35,12 @@ struct CrowdNodeBalanceReminderSheet: View { VStack(alignment: .leading, spacing: 6) { Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundStyle(Color.dash.primaryText) .multilineTextAlignment(.leading) Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundStyle(Color.dash.secondaryText) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift index 453930e7b..9dfa23940 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift @@ -119,14 +119,14 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { private func makeMetadata(for transaction: DSTransaction) -> TxRowMetadata { TxRowMetadata( icon: coinbaseIcon(), - iconName: .custom("transaction-coinbase.received", bundle: .dashUIKit), + iconName: .custom(DashIcon.Transaction.coinbaseReceived.assetName, bundle: .dashUIKit), secondaryIcon: transaction.direction == .sent - ? .custom("additional-info-sent", bundle: .dashUIKit) - : .custom("additional-info-received", bundle: .dashUIKit) + ? .custom(DashIcon.AdditionalInfo.sent.assetName, bundle: .dashUIKit) + : .custom(DashIcon.AdditionalInfo.received.assetName, bundle: .dashUIKit) ) } private func coinbaseIcon() -> UIImage? { - UIImage(named: "transaction-coinbase.received", in: .dashUIKit, compatibleWith: nil) + UIImage(named: DashIcon.Transaction.coinbaseReceived.assetName, in: .dashUIKit, compatibleWith: nil) } } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift index cdc6a9892..dc26b6323 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/GiftCardMetadataProvider.swift @@ -99,11 +99,11 @@ class GiftCardMetadataProvider: MetadataProvider, @unchecked Sendable { if txRowMetadata != nil { txRowMetadata!.title = title - txRowMetadata!.secondaryIcon = .custom("transaction-gift-card", bundle: .dashUIKit) + txRowMetadata!.secondaryIcon = .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) } else { txRowMetadata = TxRowMetadata( title: title, - secondaryIcon: .custom("transaction-gift-card", bundle: .dashUIKit) + secondaryIcon: .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) ) } @@ -128,11 +128,11 @@ class GiftCardMetadataProvider: MetadataProvider, @unchecked Sendable { if txRowMetadata != nil { txRowMetadata!.title = title - txRowMetadata!.secondaryIcon = .custom("transaction-gift-card", bundle: .dashUIKit) + txRowMetadata!.secondaryIcon = .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) } else { txRowMetadata = TxRowMetadata( title: title, - secondaryIcon: .custom("transaction-gift-card", bundle: .dashUIKit) + secondaryIcon: .custom(DashIcon.Transaction.giftCard.assetName, bundle: .dashUIKit) ) } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift index 9dd676484..f97e31fdd 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift @@ -125,7 +125,7 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { return TxRowMetadata( title: title, details: statusLabel(for: order.status), - iconName: .custom("transaction-convert", bundle: .dashUIKit), + iconName: .custom(DashIcon.Transaction.convert.assetName, bundle: .dashUIKit), secondaryIcon: secondaryIcon(for: order.status) ) } @@ -137,7 +137,7 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { private func secondaryIcon(for status: SwapOrderStatus) -> IconName? { switch status { case .refunded, .failed, .expired: - return .custom("additional-info-error", bundle: .dashUIKit) + return .custom(DashIcon.AdditionalInfo.error.assetName, bundle: .dashUIKit) case .notStarted, .pending, .swapping, .unknown, .completed: return nil } diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index ea52eb5b5..89485459b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -467,7 +467,7 @@ struct HomeViewContent: View { } if txItem.transactionType == .reward { - return (.custom("transaction-mining", bundle: .dashUIKit), nil) + return (.custom(DashIcon.Transaction.mining.assetName, bundle: .dashUIKit), nil) } return (.custom(txItem.iconName), nil) diff --git a/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift b/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift index 2f0da3800..5f019d9fb 100644 --- a/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift +++ b/DashWallet/Sources/UI/Menu/Settings/About/AboutDashView.swift @@ -71,7 +71,7 @@ struct AboutDashView: View { VStack(alignment: .center, spacing: 2) { VStack(alignment: .center, spacing: 2) { Text(NSLocalizedString("Dash Wallet is an open sourced app forked from Bitcoin Wallet", comment: "AboutDash")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.tertiaryText) .multilineTextAlignment(.center) @@ -81,7 +81,7 @@ struct AboutDashView: View { .padding(.vertical, 8) Text(NSLocalizedString("Copyright © 2026 Dash Core Group", comment: "AboutDash")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.tertiaryText) .multilineTextAlignment(.center) .padding(.horizontal, 14) @@ -105,7 +105,7 @@ struct AboutDashView: View { return AnyView( Link(destination: url) { Text(viewModel.repositoryURL) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundStyle(Color.dash.blueText) .multilineTextAlignment(.center) } diff --git a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift index 69faa759a..ad1860299 100644 --- a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift @@ -102,7 +102,7 @@ struct BuyEnterAmountView: View { if let statusMessage = viewModel.statusMessage { Text(statusMessage) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) } } @@ -112,14 +112,14 @@ struct BuyEnterAmountView: View { HStack(spacing: 8) { SwiftUI.ProgressView() Text(NSLocalizedString("Checking amount…", comment: "Dash DEX")) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) } .frame(maxWidth: .infinity, alignment: .center) .transition(.opacity) } else if let error = viewModel.inlineMessage { Text(error) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.red) .frame(maxWidth: .infinity, alignment: .center) .transition(.opacity) diff --git a/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift b/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift index 57e251e4c..4da9d056f 100644 --- a/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/Receive/BuyReceiveView.swift @@ -150,18 +150,18 @@ struct BuyReceiveView: View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text(title) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.secondaryText) Text(displayValue) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) if let helper { Text(helper) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(helperColor) .lineSpacing(3) .frame(maxWidth: .infinity, alignment: .leading) @@ -169,7 +169,7 @@ struct BuyReceiveView: View { } DashUIKit.DashButton( - leadingIcon: .custom("copy-outline", bundle: .dashUIKit), + leadingIcon: DashIcon.Icons.copyOutline.source, isEnabled: !copyValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, size: .medium, style: .tintedGray, action: { diff --git a/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift b/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift index 8ede724d9..31460e748 100644 --- a/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/RefundAddress/RefundAddressView.swift @@ -49,7 +49,7 @@ struct RefundAddressView: View { SystemMessageView( title: NSLocalizedString("The refund address is required", comment: "Dash DEX"), subtitle: NSLocalizedString("Your swap can NOT be processed without a refund address", comment: "Dash DEX"), - icon: .custom("info-rect", bundle: .dashUIKit), + icon: DashIcon.SystemMessage.infoRectSmall.source, backgroundColor: colorScheme == .dark ? Color.dash.blueAlpha10 : Color.dash.blueAlpha5, onClose: {} ) diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift index e653b2098..a45f7b7f1 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift @@ -131,16 +131,16 @@ struct SwapConvertView: View { // An error (e.g. insufficient balance after coin-mode gross-up) supersedes the // receive estimate — both can be set at once, so show the error first. Text(error) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.red) .multilineTextAlignment(.center) } else if let amount = viewModel.receiveAmount { Text(NSLocalizedString("Receive amount", comment: "Dash DEX")) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundStyle(Color.dash.tertiaryText) Text("~ \(amount)") - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundStyle(Color.dash.primaryText) } } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift index eaec298fe..b723baa97 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewFeeRow.swift @@ -37,7 +37,7 @@ struct OrderPreviewFeeRow: View { HStack(alignment: .top, spacing: labelSpacing) { HStack(spacing: infoSpacing) { Text(feeTitle) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) ZStack { @@ -58,7 +58,7 @@ struct OrderPreviewFeeRow: View { Text(secondary) } } - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .trailing) } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift index bbf0ac88a..9104d518f 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/OrderPreviewTableRow.swift @@ -30,7 +30,7 @@ struct OrderPreviewTableRow: View { var body: some View { HStack(alignment: .top, spacing: labelSpacing) { Text(leading) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -41,7 +41,7 @@ struct OrderPreviewTableRow: View { Text(secondary) } } - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift b/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift index a802bc530..5b3aa864d 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/Components/SwapFeeInfoSheet.swift @@ -29,12 +29,12 @@ struct SwapFeeInfoSheet: View { VStack(alignment: .leading, spacing: 6) { Text(NSLocalizedString("Fees in crypto purchases", comment: "Dash DEX")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .topLeading) Text(infoDescription) - .font(Font.dash.body) + .dashFont(.body) .foregroundStyle(Color.dash.gray500) .multilineTextAlignment(.leading) } diff --git a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift index fff8bbb0a..8b2f28438 100644 --- a/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift +++ b/DashWallet/Sources/UI/Swap/OrderPreview/OrderPreviewView.swift @@ -91,7 +91,7 @@ struct OrderPreviewView: View { "The final amount you receive may differ from this estimate by up to %d%% due to price slippage.", comment: "Dash DEX" ), SwapKitConstants.defaultSlippagePercent)) - .font(Font.dash.caption1) + .dashFont(.caption1) .foregroundColor(Color.dash.tertiaryText) .multilineTextAlignment(.center) .padding(.horizontal, 20) @@ -103,7 +103,7 @@ struct OrderPreviewView: View { HStack(spacing: 0) { HStack(spacing: 8) { - Image(dash: .custom("stopwatch", bundle: .dashUIKit)) + DashIcon.SystemMessage.timerSmall.image Text(NSLocalizedString("Quote expires", comment: "Dash DEX")) .dashFont(.subheadMedium) @@ -139,7 +139,7 @@ struct OrderPreviewView: View { private var fromRow: some View { HStack(spacing: Layout.labelSpacing) { Text(NSLocalizedString("From", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -149,7 +149,7 @@ struct OrderPreviewView: View { .clipShape(.rect(cornerRadius: Layout.coinLogoCornerRadius)) Text(NSLocalizedString("Dash Wallet", comment: "Dash DEX")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) } .frame(maxWidth: .infinity, alignment: .trailing) @@ -162,7 +162,7 @@ struct OrderPreviewView: View { private var toRow: some View { HStack(spacing: Layout.labelSpacing) { Text(NSLocalizedString("To", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) .fixedSize() @@ -174,7 +174,7 @@ struct OrderPreviewView: View { ) Text(viewModel.coin.name) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.primaryText) } .frame(maxWidth: .infinity, alignment: .trailing) @@ -224,11 +224,11 @@ struct OrderPreviewView: View { private var totalRow: some View { HStack(alignment: .lastTextBaseline, spacing: Layout.labelSpacing) { Text(NSLocalizedString("Total", comment: "Dash DEX")) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.tertiaryText) Text(viewModel.totalAmount) - .font(Font.dash.title3Medium) + .dashFont(.title3Medium) .foregroundColor(Color.dash.primaryText) .frame(maxWidth: .infinity, alignment: .trailing) } diff --git a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift index 667ce4c7c..7d585df5e 100644 --- a/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift +++ b/DashWallet/Sources/UI/Swap/SelectCoin/SelectCoinView.swift @@ -60,7 +60,7 @@ struct SelectCoinView: View { VStack(spacing: 0) { DashUIKit.NavigationBar( leading: { DashUIKit.NavigationBarElement.back.button { onBack?() } }, - central: { Text(NSLocalizedString("Select coin", comment: "Dash DEX")).font(Font.dash.subheadMedium) } + central: { Text(NSLocalizedString("Select coin", comment: "Dash DEX")).dashFont(.subheadMedium) } ) mainContent } @@ -159,7 +159,7 @@ struct SelectCoinView: View { VStack { VStack(spacing: Layout.emptyStateSpacing) { Text(NSLocalizedString("No coins found", comment: "Dash DEX")) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundColor(Color.dash.gray500) } .padding(10) diff --git a/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift b/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift index a25286c2e..54684d61d 100644 --- a/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift +++ b/DashWallet/Sources/UI/Swap/SwapPortalScaffold.swift @@ -65,10 +65,10 @@ struct SwapPortalScaffold: View { action: { guard isOnline else { return }; onBuyDash?() }, label: { DashUIKit.MenuItem( - leadingIcon: .custom("menu-receive", bundle: .dashUIKit), + leadingIcon: DashIcon.Menu.receive.source, isEnabled: isOnline, disabledLeadingIcon: disabledMenuIcon( - assetName: "menu-receive.disabled", + assetName: DashIcon.Menu.receiveDisabled.assetName, fallbackName: "menu-receive" ), title: NSLocalizedString("Buy Dash", comment: "Dash DEX Portal"), @@ -87,10 +87,10 @@ struct SwapPortalScaffold: View { action: { guard isOnline else { return }; onSellDash?() }, label: { DashUIKit.MenuItem( - leadingIcon: .custom("menu-send", bundle: .dashUIKit), + leadingIcon: DashIcon.Menu.send.source, isEnabled: isOnline, disabledLeadingIcon: disabledMenuIcon( - assetName: "menu-send.disabled", + assetName: DashIcon.Menu.sendDisabled.assetName, fallbackName: "menu-send" ), title: NSLocalizedString("Sell Dash", comment: "Dash DEX Portal"), @@ -116,11 +116,11 @@ struct SwapPortalScaffold: View { private var descriptionBlock: some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(Font.dash.title2) + .dashFont(.title2) .foregroundColor(Color.dash.primaryText) Text(description) - .font(Font.dash.footnote) + .dashFont(.footnote) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.leading) .lineSpacing(2) diff --git a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift index 2ddff5332..e1941b9ef 100644 --- a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift +++ b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionFailureView.swift @@ -43,12 +43,12 @@ struct SwapTransactionFailureView: View { VStack(spacing: 6) { Text(NSLocalizedString("Conversion failed", comment: "Dash DEX")) - .font(Font.dash.title1) + .dashFont(.title1) .foregroundColor(Color.dash.primaryText) .multilineTextAlignment(.center) Text(reason ?? NSLocalizedString("Your DASH was not converted and no funds were deducted. Try again, or come back later.", comment: "Dash DEX")) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.center) .lineSpacing(3) @@ -106,7 +106,7 @@ struct SwapTransactionFailureView: View { openURL(url) } label: { Text(label) - .font(Font.dash.subheadMedium) + .dashFont(.subheadMedium) .foregroundColor(Color.dash.blue) } .buttonStyle(.plain) diff --git a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift index 8c52112b8..a7d4003fe 100644 --- a/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift +++ b/DashWallet/Sources/UI/Swap/TransactionStatus/SwapTransactionPendingView.swift @@ -39,7 +39,7 @@ struct SwapTransactionPendingView: View { .multilineTextAlignment(.center) Text(message) - .font(Font.dash.subhead) + .dashFont(.subhead) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.center) .lineSpacing(3) diff --git a/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift b/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift index 1498f751f..085993320 100644 --- a/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift +++ b/DashWallet/Sources/UI/SwapKit/SwapKitPortalView.swift @@ -16,6 +16,7 @@ // import SwiftUI +import DashUIKit struct SwapKitPortalView: View { var onBack: () -> Void @@ -26,7 +27,7 @@ struct SwapKitPortalView: View { var body: some View { SwapPortalScaffold( - logoIcon: .custom("illustration-dash-dex", bundle: .dashUIKit), + logoIcon: DashIcon.Illustrations.dashDex.source, title: NSLocalizedString("Dash DEX", comment: "Dash DEX Portal"), description: NSLocalizedString( "Swap crypto into Dash, or convert Dash to any crypto supported across SwapKit networks", From 96a46f1fc4ecd39401ed663f6871007fcbc0c25c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:51:34 +0300 Subject: [PATCH 3/5] feat(dashconnect): wire the connections flow to Dash Platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the mock DashConnect screen into the real passwordless-login flow ported from Android (`dashpay/dash-wallet@feat/dash-connect`), testnet only. Protocol (`Sources/Models/DashConnect/Protocol/`) - `DashConnectUri`: `dash-key:` login and `dash-st:` key-registration URIs — `:?n=&v=1`, no authority. - `KeyExchangeCrypto` / `LoginKeyDerivation`: HKDF, AES-GCM and the login-key derivation, asserted against the Kotlin implementation's own test vectors. - `Secp256k1`: thin wrapper over the SwiftDashSDK primitives added in dashpay/platform#4273, so the port adds no third-party crypto dependency. Data layer - `PlatformDashConnectDataSource` publishes `loginKeyResponse` to the key exchange contract on approve, and completes `dash-st:` key registration by validating the app-supplied transition against locally derived keys before rebuilding it through `updateIdentity`. - `UserDefaultsDashConnectStore` keeps the connection list across launches, scoped per (network, wallet). The list is local state by design: Platform only knows whether the document exists. - `MockDashConnectDataSource` stays behind the same protocol for previews and the mainnet-unavailable state. UI - The status now follows what is true on Platform rather than which step ran: `approved` means the derived login keys are not on the identity yet, `active` means they are. `dash-st:` is first-login-only per (identity, app), so a later login with just the `dash-key:` QR lands straight on `active` instead of sticking on `approved` with a prompt for a QR the app no longer emits. - One scan entry point — the banner under an approved row — which accepts either QR code. - The row's switch removes this wallet's record of the connection, behind a confirmation that says the app may stay signed in: a website logout is invisible to the wallet, and the wallet cannot end the app's session. Also repairs the unit-test target, which was unrunnable: it now hosts on `dashpay` (the app that actually builds on this branch), the UI-test targets are out of the scheme, and the test sources import `dashpay` accordingly. Localizable.strings are deliberately left out — the build phase rewrites all 40 locales with strings from unrelated in-flight features. They go through BartyCrouch/Transifex separately. Co-Authored-By: Claude Opus 5 --- DashWallet.xcodeproj/project.pbxproj | 280 +++-- .../xcschemes/dashwallet-dashpay.xcscheme | 20 - .../DashConnect/Contents.json | 6 + .../dashconnect-empty.imageset/Contents.json | 27 + .../dashconnect-empty.png | Bin 0 -> 990 bytes .../dashconnect-empty@2x.png | Bin 0 -> 1754 bytes .../dashconnect-empty@3x.png | Bin 0 -> 2518 bytes .../dashconnect.empty.imageset/Contents.json | 10 - .../dashconnect.empty.imageset/icon.svg | 9 - .../menu-connections.imageset/Contents.json | 23 + .../menu-connections.png | Bin 0 -> 616 bytes .../menu-connections@2x.png | Bin 0 -> 992 bytes .../menu-connections@3x.png | Bin 0 -> 1471 bytes .../DashConnect/DashConnectDataSource.swift | 193 ++- .../DashConnect/DashConnectModels.swift | 66 +- .../Models/DashConnect/DashConnectStore.swift | 174 +++ .../PlatformDashConnectDataSource.swift | 1086 +++++++++++++++++ .../Protocol/DashConnectRequests.swift | 21 + .../DashConnect/Protocol/DashConnectUri.swift | 236 ++++ .../Protocol/KeyExchangeCrypto.swift | 221 ++++ .../Protocol/LoginKeyDerivation.swift | 68 ++ .../DashConnect/Protocol/Secp256k1.swift | 124 ++ .../DashConnect/ApproveConnectionSheet.swift | 187 ++- .../Components/ConnectionRow.swift | 17 +- .../Components/ConnectionStatusBadge.swift | 6 +- .../Components/ConnectionsList.swift | 6 +- .../Components/ScanToCompleteBanner.swift | 3 +- .../UI/DashConnect/ConnectionsScreen.swift | 115 +- .../UI/DashConnect/ConnectionsViewModel.swift | 104 +- .../UI/Menu/Tools/ToolsMenuViewModel.swift | 2 +- DashWalletTests/AmountObjectTests.swift | 2 +- .../DWAvatarUploadClientTests.swift | 2 +- .../DWContestedNameStatusServiceTests.swift | 2 +- .../DWRegistrationPhaseAdapterTests.swift | 2 +- .../DashAmountFormatterTests.swift | 2 +- .../DashConnectDataSourceTests.swift | 253 ++++ .../DashConnect/DashConnectStoreTests.swift | 185 +++ .../DashConnect/DashConnectUriTests.swift | 199 +++ .../DashConnect/KeyExchangeCryptoTests.swift | 172 +++ .../DashConnect/LoginKeyDerivationTests.swift | 82 ++ .../PlatformDashConnectDataSourceTests.swift | 911 ++++++++++++++ .../DashConnect/Secp256k1Tests.swift | 88 ++ .../DashPayIdentityKeysTests.swift | 2 +- .../DiagnosticLogExporterTests.swift | 2 +- .../ExchangeAddressLookupContextTests.swift | 2 +- .../PastedAmountNormalizationTests.swift | 2 +- DashWalletTests/PaymentProtocolTests.swift | 2 +- DashWalletTests/PhraseRepairEngineTests.swift | 2 +- DashWalletTests/String+DashWalletTests.swift | 2 +- .../SwapAddressValidatorTests.swift | 2 +- .../SwapKitQuoteDecodingTests.swift | 2 +- .../SwiftDashSDKCoreLifecycleTests.swift | 2 +- .../WalletWipeSerialExecutorTests.swift | 2 +- 53 files changed, 4571 insertions(+), 355 deletions(-) create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/dashconnect-empty.png create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/dashconnect-empty@2x.png create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/dashconnect-empty@3x.png delete mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/Contents.json delete mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect.empty.imageset/icon.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@2x.png create mode 100644 DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections@3x.png create mode 100644 DashWallet/Sources/Models/DashConnect/DashConnectStore.swift create mode 100644 DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift create mode 100644 DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift create mode 100644 DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift create mode 100644 DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift create mode 100644 DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift create mode 100644 DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift create mode 100644 DashWalletTests/DashConnect/DashConnectDataSourceTests.swift create mode 100644 DashWalletTests/DashConnect/DashConnectStoreTests.swift create mode 100644 DashWalletTests/DashConnect/DashConnectUriTests.swift create mode 100644 DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift create mode 100644 DashWalletTests/DashConnect/LoginKeyDerivationTests.swift create mode 100644 DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift create mode 100644 DashWalletTests/DashConnect/Secp256k1Tests.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 0aea6ddfa..526ec4358 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 02EDCC7635BCD84768554D9C /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */; }; + 0438F3D117619B14594F95E6 /* DashConnectUriTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */; }; 06132521E7B4D21B4A1597E7 /* PlatformSendExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DCDC3A2B92601BB1E61FD9 /* PlatformSendExecutor.swift */; }; 06E16A070A3C48883BBDA137 /* BIP70InteractiveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36BCF99A4D30077C9C9B0EB /* BIP70InteractiveCoordinator.swift */; }; 07B6BD15F525AAF000B53162 /* AddContactScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3498B2E247B2CADBF02DC0B1 /* AddContactScreen.swift */; }; @@ -101,7 +102,7 @@ 16AA1B384A9758C1DA97676A /* SwiftDashSDKHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */; }; 17ED79A8B56522CD03A8CD5E /* ShieldedTransferCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */; }; 197F19679BBAABA2E99503B8 /* DWAvatarUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A461F75E1E170FFE3294337 /* DWAvatarUploadClient.swift */; }; - 19BF3F10240A082811AAC1EB /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + 19BF3F10240A082811AAC1EB /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 19CF9D60B276558AA48380C2 /* SwiftDashSDKSPVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */; }; 1A9AA76AC3F7489DCE8850ED /* WalletAccountsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F334C7FE582BF48AB47BDAA2 /* WalletAccountsViewModel.swift */; }; 1AF81D8C73A81CB927C3AE0E /* libPods-DashWalletScreenshotsUITests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */; }; @@ -434,7 +435,6 @@ 478C98372943A60000FAA0F0 /* PaymentMethodCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C98362943A60000FAA0F0 /* PaymentMethodCell.swift */; }; 478C983C2945801D00FAA0F0 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C983B2945801D00FAA0F0 /* Constants.swift */; }; 47976E4A2987772700612988 /* AmountObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47976E492987772700612988 /* AmountObjectTests.swift */; }; - 47976E4B2987781A00612988 /* DWAmountInputValidator.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A2120E92214566A009906DC /* DWAmountInputValidator.m */; }; 479DBDDD2995168C00F30AF1 /* Transactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 479DBDDC2995168C00F30AF1 /* Transactions.swift */; }; 479E7924287C00EC00D0F7D7 /* DatabaseConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 479E7923287C00EC00D0F7D7 /* DatabaseConnection.swift */; }; 47A184E5299CC19A0017E32C /* rates.json in Resources */ = {isa = PBXBuildFile; fileRef = 47A184E4299CC19A0017E32C /* rates.json */; }; @@ -725,16 +725,16 @@ 754119E01CDA93FF0042DC51 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAE12BE61B2DEE7F00895CC5 /* NotificationCenter.framework */; }; 754495DD2AE91B6300492817 /* GroupedRequestCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754495DC2AE91B6300492817 /* GroupedRequestCell.swift */; }; 754495DF2AE91D3500492817 /* UsernameRequestCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754495DE2AE91D3500492817 /* UsernameRequestCell.swift */; }; - 754565C82DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */; }; - 754565CA2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */; }; - 754565CB2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */; }; - 754565CC2DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */; }; - 754565CE2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */; }; - 754565CF2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */; }; + 754565C82DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */; }; + 754565CA2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */; }; + 754565CB2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */; }; + 754565CC2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */; }; + 754565CE2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */; }; + 754565CF2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */; }; 754565D12DABA5F300DA4E8E /* MerchantTypesDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */; }; 754565D22DABA5F300DA4E8E /* MerchantTypesDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */; }; - 7545ED532DA91A2F0075F45C /* CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* CTXConstants.swift */; }; - 7545ED542DA91A2F0075F45C /* CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* CTXConstants.swift */; }; + 7545ED532DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */; }; + 7545ED542DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */; }; 7545ED572DA91AF10075F45C /* DashSpendUserAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */; }; 7545ED582DA91AF10075F45C /* DashSpendUserAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */; }; 7545ED5D2DA91F590075F45C /* DashSpendTermsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7545ED5C2DA91F590075F45C /* DashSpendTermsScreen.swift */; }; @@ -825,8 +825,8 @@ 759C8FA02B593589004B1305 /* CrowdNodeAPYView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759C8F9E2B593589004B1305 /* CrowdNodeAPYView.swift */; }; 75A0A3F32CA7DBCF003ED48B /* TimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */; }; 75A0A3F42CA7DBCF003ED48B /* TimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */; }; - 75A2F3032DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */; }; - 75A2F3042DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */; }; + 75A2F3032DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */; }; + 75A2F3042DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */; }; 75A8C1652AE5726B0042256E /* UsernameRequestsDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1632AE5725C0042256E /* UsernameRequestsDAO.swift */; }; 75A8C1672AE5734A0042256E /* UsernameRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1662AE5734A0042256E /* UsernameRequest.swift */; }; 75A8C1692AE6A1AC0042256E /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; @@ -941,6 +941,7 @@ 8A7607606A594C6E99A2FAC7 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; 8AF94168DE3494EAF0811D77 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 8D66A1B0E97F5B50411765B4 /* Secp256k1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */; }; 8DD6758D2AC6F5BB23D5504D /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 8DF3D24EE3B247B79AE9CAB5 /* BuySellPortalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */; }; 8E3AF21ED19AEC0FD89A63FC /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; @@ -995,8 +996,8 @@ AA0000012DUMMYID001234567 /* AddProviderToGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */; }; AA00000228D9A3DB00223B77 /* BuySellPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000028D9A3DB00223B77 /* BuySellPortalView.swift */; }; AA0000022DUMMYID001234567 /* AddProviderToGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */; }; - AA0000042DUMMYID001234567 /* GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* GiftCardProvider.swift */; }; - AA0000052DUMMYID001234567 /* GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* GiftCardProvider.swift */; }; + AA0000042DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */; }; + AA0000052DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */; }; AA0001022AA9F58E00A1B201 /* MayaPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */; }; AA0001032AA9F58E00A1B201 /* MayaPortalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */; }; AA0001052AA9F58E00A1B202 /* MayaPortalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001042AA9F58E00A1B202 /* MayaPortalViewController.swift */; }; @@ -1083,12 +1084,17 @@ AA0031022FD0000000000012 /* IconName+DashIconSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0031012FD0000000000011 /* IconName+DashIconSource.swift */; }; AA0031032FD0000000000013 /* IconName+DashIconSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0031012FD0000000000011 /* IconName+DashIconSource.swift */; }; AA00F1012FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */; }; + AA03A0012FF3000000000001 /* KeyExchangeCryptoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */; }; + AA03A0022FF3000000000002 /* LoginKeyDerivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */; }; + AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */; }; + AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */; }; + AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */; }; AA22BB33CC44DD55EE66FF78 /* BuyReceiveFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */; }; AA29B7041CF55787428E1A04 /* libPods-WatchApp Extension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B9BF7550EF8D4BA009CB1EB5 /* libPods-WatchApp Extension.a */; }; ABA7230759067F4D4EB39AE9 /* SwapTransactionStatusHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B5469F5588395FA0668C4B /* SwapTransactionStatusHostingController.swift */; }; AC938F974CADCEA4AC61B922 /* StorageExplorerUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F8E2ECDE79849D01F613ADC /* StorageExplorerUnavailableView.swift */; }; ACAB47BF67F9E00D253166D4 /* DWRegistrationPhaseAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3839568283FDC3A6933824 /* DWRegistrationPhaseAdapter.swift */; }; - AEAE60406265A73AFD573FD1 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + AEAE60406265A73AFD573FD1 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; B059B6E200A57A699E6EED84 /* libPods-DashWalletTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1940CA64E8FD6DDD0CFC14A2 /* libPods-DashWalletTests.a */; }; B1100000B1100000B1100010 /* BuyEnterAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1100000B1100000B1100002 /* BuyEnterAmountView.swift */; }; B1100000B1100000B1100011 /* BuyEnterAmountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1100000B1100000B1100003 /* BuyEnterAmountViewModel.swift */; }; @@ -1147,12 +1153,12 @@ BAA6E3EF1BD5CA0E00773205 /* BRAWBalanceInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA6E3EE1BD5CA0E00773205 /* BRAWBalanceInterfaceController.swift */; }; BAA6E3F11BD5CA5900773205 /* BRAWReceiveMoneyInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA6E3F01BD5CA5900773205 /* BRAWReceiveMoneyInterfaceController.swift */; }; BAE12C061B2DEEF700895CC5 /* DWTodayViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BAE12C051B2DEEF700895CC5 /* DWTodayViewController.m */; }; - BB0000012DUMMYID001234567 /* DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpendError.swift */; }; - BB0000022DUMMYID001234567 /* DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpendError.swift */; }; - BB0000032DUMMYID001234567 /* DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpendRepository.swift */; }; - BB0000042DUMMYID001234567 /* DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpendRepository.swift */; }; - BB0000052DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */; }; - BB0000062DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */; }; + BB0000012DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */; }; + BB0000022DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */; }; + BB0000032DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */; }; + BB0000042DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */; }; + BB0000052DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */; }; + BB0000062DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */; }; BB779AE4D9B43803EAF0CB6F /* WalletEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0B188220763649450C9423 /* WalletEnvironment.swift */; }; BC2677B2880883067AE18DC5 /* libPods-dashwallet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 797D9070BF54A3533190584E /* libPods-dashwallet.a */; }; BCBED608CAAABA10839A0ADE /* SwiftDashSDKWalletState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C22064915D913252F56184 /* SwiftDashSDKWalletState.swift */; }; @@ -1390,7 +1396,7 @@ C9D2C70C2A320AA000D15901 /* DWUpholdLogoutTutorialViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9FFE652230FF4600956D5F /* DWUpholdLogoutTutorialViewController.m */; }; C9D2C70D2A320AA000D15901 /* CBAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CF46A429654E190067B6EE /* CBAccount.swift */; }; C9D2C70E2A320AA000D15901 /* Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF180429070B720025803E /* Types.swift */; }; - C9D2C70F2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C70F2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7112A320AA000D15901 /* Coinbase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4774DCDA28F3FA9C008CF87D /* Coinbase.swift */; }; C9D2C7122A320AA000D15901 /* SyncModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F452072A11F28600825057 /* SyncModel.swift */; }; C9D2C7132A320AA000D15901 /* ReceiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FAA29DC1098001BC549 /* ReceiveContentView.swift */; }; @@ -1410,7 +1416,7 @@ C9D2C7242A320AA000D15901 /* WKWebView+CrowdNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110D1780298BA9AF005BEB30 /* WKWebView+CrowdNode.swift */; }; C9D2C7252A320AA000D15901 /* SyncingActivityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FA3AFE29350929008D58DC /* SyncingActivityMonitor.swift */; }; C9D2C7262A320AA000D15901 /* EmptyUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FAF29DC27F4001BC549 /* EmptyUIView.swift */; }; - C9D2C7272A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7272A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7282A320AA000D15901 /* DWQRScanModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AC92C891FEB0B8B008CAEE0 /* DWQRScanModel.m */; }; C9D2C72A2A320AA000D15901 /* SyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F451F62A0CAE1300825057 /* SyncView.swift */; }; C9D2C72B2A320AA000D15901 /* SuccessfulOperationStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47522F4F2927CB9000EE143E /* SuccessfulOperationStatusViewController.swift */; }; @@ -1441,7 +1447,7 @@ C9D2C7492A320AA000D15901 /* DWCurrencyObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AA87CF926E5681100F0CEA6 /* DWCurrencyObject.m */; }; C9D2C74A2A320AA000D15901 /* ShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94F5E8F29D4060A0034FD57 /* ShortcutsView.swift */; }; C9D2C74B2A320AA000D15901 /* DWFormSectionModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9FFE3E2230FF4600956D5F /* DWFormSectionModel.m */; }; - C9D2C74C2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C74C2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C74D2A320AA000D15901 /* CoinbasePlaceBuyOrderResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA628C896BC000427E7 /* CoinbasePlaceBuyOrderResponse.swift */; }; C9D2C74E2A320AA000D15901 /* ExploreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BDC28C1305E00490F5E /* ExploreMapView.swift */; }; C9D2C74F2A320AA000D15901 /* DWSeedPhraseRow.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AD1CE9322DD078600C99324 /* DWSeedPhraseRow.m */; }; @@ -1463,7 +1469,7 @@ C9D2C7642A320AA000D15901 /* BasePageSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F2C67C28602D4F00C2B774 /* BasePageSheetViewController.swift */; }; C9D2C7662A320AA000D15901 /* CrowdNodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 119E8D0329051F9900D406C1 /* CrowdNodeRequest.swift */; }; C9D2C7672A320AA000D15901 /* CoinbaseExchangeRateResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFAF28C896BC000427E7 /* CoinbaseExchangeRateResponse.swift */; }; - C9D2C7692A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7692A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C76A2A320AA000D15901 /* CrowdNodePortalItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 117728A2297A7D24006F1553 /* CrowdNodePortalItem.swift */; }; C9D2C76B2A320AA000D15901 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C661B528FE75A700028A8D /* BaseViewController.swift */; }; C9D2C76C2A320AA000D15901 /* AccountListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CABF296EFD2900F63AC4 /* AccountListController.swift */; }; @@ -1512,11 +1518,11 @@ C9D2C7A52A320AA000D15901 /* DWModalPresentationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C69C523142AA4001B8C90 /* DWModalPresentationController.m */; }; C9D2C7A72A320AA000D15901 /* ActionButtonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C661B328FDCF7800028A8D /* ActionButtonViewController.swift */; }; C9D2C7A82A320AA000D15901 /* BuySellServiceItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4774DCE428F4668B008CF87D /* BuySellServiceItemCell.swift */; }; - C9D2C7AA2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7AA2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7AB2A320AA000D15901 /* DWBaseFormTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BC82347E0D700451078 /* DWBaseFormTableViewCell.m */; }; C9D2C7AC2A320AA000D15901 /* AccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CAC6296FAEBB00F63AC4 /* AccountCell.swift */; }; C9D2C7B12A320AA000D15901 /* CoinbaseBaseIDForCurrencyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA028C896BC000427E7 /* CoinbaseBaseIDForCurrencyResponse.swift */; }; - C9D2C7B22A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7B22A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7B32A320AA000D15901 /* SpecifyAmountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AC8412297822E000BD1B49 /* SpecifyAmountViewController.swift */; }; C9D2C7B42A320AA000D15901 /* DWUpholdTransactionObject+DWView.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AFCB9C323BE76EC00FF59A6 /* DWUpholdTransactionObject+DWView.m */; }; C9D2C7B62A320AA000D15901 /* CrowdNodeDepositTx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114CFECF296469D9005F421B /* CrowdNodeDepositTx.swift */; }; @@ -1527,14 +1533,14 @@ C9D2C7BD2A320AA000D15901 /* DWSharedUIConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B9E2822FB1C5D00FF8653 /* DWSharedUIConstants.m */; }; C9D2C7BE2A320AA000D15901 /* BRAppleWatchTransactionData.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A858A0E237EE89B0097A7B5 /* BRAppleWatchTransactionData.m */; }; C9D2C7C02A320AA000D15901 /* HomeHeaderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F4520A2A1209D100825057 /* HomeHeaderModel.swift */; }; - C9D2C7C22A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7C22A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7C32A320AA000D15901 /* DWDPRegistrationStatus.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF01DA243F4559003718DC /* DWDPRegistrationStatus.m */; }; C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 472D13EC299E6579006903F1 /* CurrencyExchanger_Objc.m */; }; C9D2C7C62A320AA000D15901 /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471DD1B7290A92CD00E030C8 /* Tools.swift */; }; C9D2C7C72A320AA000D15901 /* BuySellPortalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478A2C7028DC554200AD1420 /* BuySellPortalModel.swift */; }; C9D2C7C92A320AA000D15901 /* BuyDashViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C98252942DC2700FAA0F0 /* BuyDashViewController.swift */; }; C9D2C7CA2A320AA000D15901 /* DWModalDismissalAnimation.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C69CF23143435001B8C90 /* DWModalDismissalAnimation.m */; }; - C9D2C7CB2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C7CB2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C7CD2A320AA000D15901 /* UIViewController+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CDEECD294A3CF2008AE06D /* UIViewController+DashWallet.swift */; }; C9D2C7CE2A320AA000D15901 /* CrowdNodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470617D5299A671900DCC667 /* CrowdNodeCell.swift */; }; C9D2C7CF2A320AA000D15901 /* MerchantListLocationOffCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BD128C1305E00490F5E /* MerchantListLocationOffCell.swift */; }; @@ -1625,7 +1631,7 @@ C9D2C8452A320AA000D15901 /* DWRecoverViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A74EFEC2305318000C475EB /* DWRecoverViewController.m */; }; C9D2C8482A320AA000D15901 /* CoinbaseTransactionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA828C896BC000427E7 /* CoinbaseTransactionsRequest.swift */; }; C9D2C8492A320AA000D15901 /* DWAppRootViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A9CEBAC22E1DA4000A50237 /* DWAppRootViewController.m */; }; - C9D2C84A2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C84A2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C84B2A320AA000D15901 /* UITableView+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F005FE297164600029EB10 /* UITableView+DashWallet.swift */; }; C9D2C84D2A320AA000D15901 /* ShortcutCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94F5E8D29D404850034FD57 /* ShortcutCell.swift */; }; C9D2C84F2A320AA000D15901 /* UIColor+DWStyle.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A44312022CCA2A0009BAF7F /* UIColor+DWStyle.m */; }; @@ -1646,7 +1652,7 @@ C9D2C8612A320AA000D15901 /* CoinbaseTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFA328C896BC000427E7 /* CoinbaseTokenResponse.swift */; }; C9D2C8622A320AA000D15901 /* BackupInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F42FB129DD5141001BC549 /* BackupInfoViewController.swift */; }; C9D2C8652A320AA000D15901 /* DWCaptureSessionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BAD234770C900451078 /* DWCaptureSessionManager.m */; }; - C9D2C86A2A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C86A2A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C86B2A320AA000D15901 /* AmountPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472D13E5299E3C81006903F1 /* AmountPreviewView.swift */; }; C9D2C86C2A320AA000D15901 /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4759D513292FEFFB002F20DC /* ModalNavigationController.swift */; }; C9D2C86E2A320AA000D15901 /* TransferAmountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A50F3A2913BC0900C70123 /* TransferAmountModel.swift */; }; @@ -1679,7 +1685,7 @@ C9D2C8952A320AA000D15901 /* ConvertCryptoOrderPreviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4751CACF2970224D00F63AC4 /* ConvertCryptoOrderPreviewModel.swift */; }; C9D2C8972A320AA000D15901 /* RatesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A2E3A82972B15F0032A63B /* RatesProvider.swift */; }; C9D2C8982A320AA000D15901 /* CBAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A2A2E8293E612900938DB7 /* CBAuth.swift */; }; - C9D2C8992A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C8992A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C89A2A320AA000D15901 /* CoinbaseCreateAddressesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F6EDFAE28C896BC000427E7 /* CoinbaseCreateAddressesRequest.swift */; }; C9D2C89E2A320AA000D15901 /* AddressUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471A2604289ACD5C0056B7B2 /* AddressUserInfo.swift */; }; C9D2C8A02A320AA000D15901 /* SendCoinsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E47BAA28EB38510097CFA0 /* SendCoinsService.swift */; }; @@ -1725,7 +1731,7 @@ C9D2C8DD2A320AA000D15901 /* AtmDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8BD528C1305E00490F5E /* AtmDataProvider.swift */; }; C9D2C8DE2A320AA000D15901 /* GettingStartedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110C67942921147F006B580C /* GettingStartedViewController.swift */; }; C9D2C8DF2A320AA000D15901 /* NetworkUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477A963D292CD27D0013605B /* NetworkUnavailableView.swift */; }; - C9D2C8E02A320AA000D15901 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + C9D2C8E02A320AA000D15901 /* (null) in Sources */ = {isa = PBXBuildFile; }; C9D2C8E12A320AA000D15901 /* DWLockScreenViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A63004D2328F37C00827825 /* DWLockScreenViewController.m */; }; C9D2C8E22A320AA000D15901 /* UpholdTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4789D2302981069700BAFEFA /* UpholdTransferViewController.swift */; }; C9D2C8E32A320AA000D15901 /* DWVerifySeedPhraseViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AD1CE8322DC9B5D00C99324 /* DWVerifySeedPhraseViewController.m */; }; @@ -1763,7 +1769,7 @@ C9D2C9272A320AA000D15901 /* SyncView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2A4E531922EA382B00E5168A /* SyncView.xib */; }; C9D2C9292A320AA000D15901 /* BackupInfoItemView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C9F42FB529DD8702001BC549 /* BackupInfoItemView.xib */; }; C9D2C92A2A320AA000D15901 /* explore.db in Resources */ = {isa = PBXBuildFile; fileRef = 47AE8BAF28BFF28400490F5E /* explore.db */; }; - C9D2C92B2A320AA000D15901 /* BuildFile in Resources */ = {isa = PBXBuildFile; }; + C9D2C92B2A320AA000D15901 /* (null) in Resources */ = {isa = PBXBuildFile; }; C9D2C92C2A320AA000D15901 /* Coinbase.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0F3693812919A70B007F4E91 /* Coinbase.storyboard */; }; C9D2C92F2A320AA000D15901 /* ReceiveContentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C9F42FAC29DC115A001BC549 /* ReceiveContentView.xib */; }; C9D2C9302A320AA000D15901 /* VerifySeedPhrase.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2AD1CE8522DC9B7300C99324 /* VerifySeedPhrase.storyboard */; }; @@ -1861,7 +1867,7 @@ DC485FD6F26BDE9D539EFA04 /* SwiftDashSDKWalletWiper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DF22D94904D5A8CD596ED8 /* SwiftDashSDKWalletWiper.swift */; }; DD985ADD7FFF3B9E3C344DED /* SwiftDashSDKSPVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */; }; DE146DFF383C96BD9DDD692A /* WalletAccountDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0199D74601A6150ABC8D1 /* WalletAccountDetailViewModel.swift */; }; - DE3A167A235B79D705C0A962 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + DE3A167A235B79D705C0A962 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; E026CE440F583B5B64395214 /* BIP70SendAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F0351A49BFB8CCA87926D /* BIP70SendAuthorizer.swift */; }; E039AE9F21B1A3BF80B16038 /* PinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FE91D9C90E736DB1634A5 /* PinStore.swift */; }; E16E2BA180551E3B6661B9F4 /* PlatformCreditsFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C830DD6D63115525925F75 /* PlatformCreditsFormatter.swift */; }; @@ -1906,8 +1912,8 @@ FBE008F4BBBA9CC7BF299B93 /* PaymentProtocolTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3B307E94B354351D4E3D3B /* PaymentProtocolTransport.swift */; }; FBEF3AF121823CD800917AB6 /* DWEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = FBEF3AF021823CD800917AB6 /* DWEnvironment.m */; }; FCDF0FF6DC442DBE3C9713FD /* DiagnosticLogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0448A6769BCF20987BE9960C /* DiagnosticLogExporter.swift */; }; - FF0000012DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */; }; - FF0000022DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */; }; + FF0000012DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */; }; + FF0000022DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */; }; FF0000052DUMMYID001234567 /* PiggyCardsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */; }; FF0000072DUMMYID001234567 /* PiggyCardsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */; }; FF0000082DUMMYID001234567 /* BarcodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0000092DUMMYID001234567 /* BarcodeScanner.swift */; }; @@ -1935,8 +1941,8 @@ isa = PBXContainerItemProxy; containerPortal = 75D5F3B6191EC270004AB296 /* Project object */; proxyType = 1; - remoteGlobalIDString = 75D5F3BD191EC270004AB296; - remoteInfo = DashWallet; + remoteGlobalIDString = C9D2C68A2A320AA000D15901; + remoteInfo = dashpay; }; BA913BEC1BD57E4D005A7C0E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -2289,6 +2295,7 @@ 2A7A7C1C234B771400451078 /* DWLocalCurrencyModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWLocalCurrencyModel.m; sourceTree = ""; }; 2A7A7C1E234B79B700451078 /* DWLocalCurrencyTableViewCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWLocalCurrencyTableViewCell.h; sourceTree = ""; }; 2A7A7C1F234B79B700451078 /* DWLocalCurrencyTableViewCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWLocalCurrencyTableViewCell.m; sourceTree = ""; }; + 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectUriTests.swift; path = DashConnect/DashConnectUriTests.swift; sourceTree = ""; }; 2A7F3B19238C646600DEA3EF /* DWAdvancedSecurityViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWAdvancedSecurityViewController.h; sourceTree = ""; }; 2A7F3B1A238C646600DEA3EF /* DWAdvancedSecurityViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWAdvancedSecurityViewController.m; sourceTree = ""; }; 2A7F3B1D238C651200DEA3EF /* DWSecurityStatusView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWSecurityStatusView.h; sourceTree = ""; }; @@ -2876,11 +2883,11 @@ 753FDBED2AECF52B0005EEC3 /* UsernameVoting.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = UsernameVoting.storyboard; sourceTree = ""; }; 754495DC2AE91B6300492817 /* GroupedRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedRequestCell.swift; sourceTree = ""; }; 754495DE2AE91D3500492817 /* UsernameRequestCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequestCell.swift; sourceTree = ""; }; - 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendAPI.swift; sourceTree = ""; }; - 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendEndpoint.swift; sourceTree = ""; }; - 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendRepository.swift; sourceTree = ""; }; + 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendAPI.swift; sourceTree = ""; }; + 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendEndpoint.swift; sourceTree = ""; }; + 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendRepository.swift; sourceTree = ""; }; 754565D02DABA5EB00DA4E8E /* MerchantTypesDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantTypesDialog.swift; sourceTree = ""; }; - 7545ED522DA91A2F0075F45C /* CTXConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTXConstants.swift; sourceTree = ""; }; + 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTXConstants.swift; sourceTree = ""; }; 7545ED562DA91AEA0075F45C /* DashSpendUserAuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpendUserAuthViewModel.swift; sourceTree = ""; }; 7545ED5C2DA91F590075F45C /* DashSpendTermsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpendTermsScreen.swift; sourceTree = ""; }; 7549BE522DEAEA14004F0BAF /* MetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataProvider.swift; sourceTree = ""; }; @@ -2943,7 +2950,7 @@ 759AFDE22CC67E89007072D2 /* VotingInfoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingInfoScreen.swift; sourceTree = ""; }; 759C8F9E2B593589004B1305 /* CrowdNodeAPYView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrowdNodeAPYView.swift; sourceTree = ""; }; 75A0A3F22CA7DBCF003ED48B /* TimeUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeUtils.swift; sourceTree = ""; }; - 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendTokenService.swift; sourceTree = ""; }; + 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/CTX/CTXSpendTokenService.swift; sourceTree = ""; }; 75A8C1632AE5725C0042256E /* UsernameRequestsDAO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequestsDAO.swift; sourceTree = ""; }; 75A8C1662AE5734A0042256E /* UsernameRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameRequest.swift; sourceTree = ""; }; 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingViewModel.swift; sourceTree = ""; }; @@ -3025,6 +3032,7 @@ 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletScreenshotsUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalScreen.swift; sourceTree = ""; }; 7BC16BFBFFFA4E488C09164D /* DSAccount+SpentInputCheck.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DSAccount+SpentInputCheck.m"; sourceTree = ""; }; + 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Secp256k1Tests.swift; path = DashConnect/Secp256k1Tests.swift; sourceTree = ""; }; 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKIdentityProfileSheet.swift; sourceTree = ""; }; 7FA744C4A6023E26A3B5B860 /* DWLogger.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = DWLogger.h; sourceTree = ""; }; 82926C69F3BB675BEF1A1FFD /* DWProfileUpdateCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWProfileUpdateCoordinator.swift; sourceTree = ""; }; @@ -3053,7 +3061,7 @@ A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKReceiveAddressReader.swift; sourceTree = ""; }; AA00000028D9A3DB00223B77 /* BuySellPortalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalView.swift; sourceTree = ""; }; AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProviderToGiftCardsTable.swift; sourceTree = ""; }; - AA0000062DUMMYID001234567 /* GiftCardProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/GiftCardProvider.swift; sourceTree = ""; }; + AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/GiftCardProvider.swift; sourceTree = ""; }; AA0001012AA9F58E00A1B201 /* MayaPortalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaPortalView.swift; sourceTree = ""; }; AA0001042AA9F58E00A1B202 /* MayaPortalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaPortalViewController.swift; sourceTree = ""; }; AA0002012BA0F58E00A1B301 /* SwapCryptoCurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapCryptoCurrency.swift; sourceTree = ""; }; @@ -3099,6 +3107,11 @@ AA0030202FD0000000000020 /* CoinbaseMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseMetadataProvider.swift; sourceTree = ""; }; AA0031012FD0000000000011 /* IconName+DashIconSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IconName+DashIconSource.swift"; sourceTree = ""; }; AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangeAddressLookupContextTests.swift; sourceTree = ""; }; + AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeyExchangeCryptoTests.swift; path = DashConnect/KeyExchangeCryptoTests.swift; sourceTree = ""; }; + AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginKeyDerivationTests.swift; path = DashConnect/LoginKeyDerivationTests.swift; sourceTree = ""; }; + AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectDataSourceTests.swift; path = DashConnect/DashConnectDataSourceTests.swift; sourceTree = ""; }; + AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DashConnectStoreTests.swift; path = DashConnect/DashConnectStoreTests.swift; sourceTree = ""; }; + AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlatformDashConnectDataSourceTests.swift; path = DashConnect/PlatformDashConnectDataSourceTests.swift; sourceTree = ""; }; AA22BB33CC44DD55EE66FF77 /* BuyReceiveFlowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuyReceiveFlowUITests.swift; sourceTree = ""; }; AA29A8515217AC4232F794C2 /* Pods-TodayExtension.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TodayExtension.testnet.xcconfig"; path = "Target Support Files/Pods-TodayExtension/Pods-TodayExtension.testnet.xcconfig"; sourceTree = ""; }; AD6615F1246B5935365B05BD /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; @@ -3153,9 +3166,9 @@ BAE12BE61B2DEE7F00895CC5 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; BAE12BEA1B2DEE7F00895CC5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BAE12C051B2DEEF700895CC5 /* DWTodayViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DWTodayViewController.m; sourceTree = ""; }; - BB0000072DUMMYID001234567 /* DashSpendError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendError.swift; sourceTree = ""; }; - BB0000082DUMMYID001234567 /* DashSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepository.swift; sourceTree = ""; }; - BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepositoryFactory.swift; sourceTree = ""; }; + BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendError.swift; sourceTree = ""; }; + BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepository.swift; sourceTree = ""; }; + BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/DashSpendRepositoryFactory.swift; sourceTree = ""; }; BCD0199D74601A6150ABC8D1 /* WalletAccountDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletAccountDetailViewModel.swift; sourceTree = ""; }; C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrderPreviewFeeRow.swift; sourceTree = ""; }; C0DE5A0126C7000000000002 /* SwiftDashSDKCoreLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKCoreLifecycleTests.swift; sourceTree = ""; }; @@ -3655,7 +3668,7 @@ FBF3F4301E42B02800C7248E /* ImageIO.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = System/Library/Frameworks/ImageIO.framework; sourceTree = SDKROOT; }; FE0000000000000000000061 /* ExtendedPublicKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtendedPublicKeysView.swift; path = DashWallet/Sources/UI/Menu/Tools/ExtendedKeys/ExtendedPublicKeysView.swift; sourceTree = SOURCE_ROOT; }; FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingViewModel.swift; sourceTree = ""; }; - FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/PiggyCardsConstants.swift; sourceTree = ""; }; + FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpend/PiggyCardsConstants.swift; sourceTree = ""; }; FF0000062DUMMYID001234567 /* PiggyCardsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsModels.swift; sourceTree = ""; }; FF0000092DUMMYID001234567 /* BarcodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScanner.swift; sourceTree = ""; }; GG0000032DUMMYID001234567 /* GeoRestrictionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRestrictionService.swift; sourceTree = ""; }; @@ -3665,39 +3678,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 51DC0001300000000000DC01 /* DashConnect */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = DashConnect; - sourceTree = ""; - }; - 51DC0002300000000000DC02 /* DashConnect */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = DashConnect; - sourceTree = ""; - }; - 51DFA7FB2FA8EAF2001EDB3A /* Components */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Components; - sourceTree = ""; - }; + 51DC0001300000000000DC01 /* DashConnect */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DashConnect; sourceTree = ""; }; + 51DC0002300000000000DC02 /* DashConnect */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DashConnect; sourceTree = ""; }; + 51DFA7FB2FA8EAF2001EDB3A /* Components */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Components; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -3714,8 +3697,8 @@ buildActionMask = 2147483647; files = ( 510336ED2FEBB1B4002E0899 /* DashUIKit in Frameworks */, - DE3A167A235B79D705C0A962 /* BuildFile in Frameworks */, - AEAE60406265A73AFD573FD1 /* BuildFile in Frameworks */, + DE3A167A235B79D705C0A962 /* (null) in Frameworks */, + AEAE60406265A73AFD573FD1 /* (null) in Frameworks */, C95D147D8F600AF97EC17E99 /* SwiftDashSDK in Frameworks */, BC2677B2880883067AE18DC5 /* libPods-dashwallet.a in Frameworks */, ); @@ -3725,7 +3708,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 19BF3F10240A082811AAC1EB /* BuildFile in Frameworks */, + 19BF3F10240A082811AAC1EB /* (null) in Frameworks */, B059B6E200A57A699E6EED84 /* libPods-DashWalletTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6203,9 +6186,9 @@ 47AE8B9628BFACE800490F5E /* Model */ = { isa = PBXGroup; children = ( - 7545ED522DA91A2F0075F45C /* CTXConstants.swift */, - AA0000062DUMMYID001234567 /* GiftCardProvider.swift */, - FF0000042DUMMYID001234567 /* PiggyCardsConstants.swift */, + 7545ED522DA91A2F0075F45C /* DashSpend/CTXConstants.swift */, + AA0000062DUMMYID001234567 /* DashSpend/GiftCardProvider.swift */, + FF0000042DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift */, 47AE8B9A28BFAD2000490F5E /* Entites */, 47AE8B9928BFAD0B00490F5E /* DAO */, ); @@ -6225,15 +6208,15 @@ isa = PBXGroup; children = ( FF0000092DUMMYID001234567 /* BarcodeScanner.swift */, - 75A2F3022DE48C860046BE17 /* CTXSpendTokenService.swift */, - 754565C42DAA52A000DA4E8E /* CTXSpendAPI.swift */, - 754565C52DAA52A000DA4E8E /* CTXSpendEndpoint.swift */, - 754565C72DAA52A000DA4E8E /* CTXSpendRepository.swift */, - BB0000072DUMMYID001234567 /* DashSpendError.swift */, - BB0000082DUMMYID001234567 /* DashSpendRepository.swift */, - BB0000092DUMMYID001234567 /* DashSpendRepositoryFactory.swift */, + 75A2F3022DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift */, + 754565C42DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift */, + 754565C52DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift */, + 754565C72DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift */, + BB0000072DUMMYID001234567 /* DashSpend/DashSpendError.swift */, + BB0000082DUMMYID001234567 /* DashSpend/DashSpendRepository.swift */, + BB0000092DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift */, GG0000032DUMMYID001234567 /* GeoRestrictionService.swift */, - DD0000012DUMMYID001234567 /* PiggyCards */, + DD0000012DUMMYID001234567 /* DashSpend/PiggyCards */, 47AE8BAC28BFAE6700490F5E /* DWLocationManager.swift */, 47AE8BA828BFAE5800490F5E /* ExploreDatabaseSyncManager.swift */, ); @@ -7105,6 +7088,13 @@ 471DD1BE290AE9F000E030C8 /* DashWalletTests-Bridging-Header.h */, 2A2120EB22145CAE009906DC /* DWAmountInputValidatorTests.m */, 471DD1BB290AE30B00E030C8 /* String+DashWalletTests.swift */, + 2A7CAFFF191922CBF6075212 /* DashConnectUriTests.swift */, + 7C172F43568AE0A5AFDB5349 /* Secp256k1Tests.swift */, + AA03A0032FF3000000000003 /* KeyExchangeCryptoTests.swift */, + AA03A0042FF3000000000004 /* LoginKeyDerivationTests.swift */, + AA04A0032FF4000000000003 /* DashConnectDataSourceTests.swift */, + AA05A0032FF5000000000003 /* DashConnectStoreTests.swift */, + AA06A0032FF6000000000003 /* PlatformDashConnectDataSourceTests.swift */, 47976E492987772700612988 /* AmountObjectTests.swift */, A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */, B7C072AA26C7000000000002 /* DWContestedNameStatusServiceTests.swift */, @@ -8578,7 +8568,7 @@ path = Components; sourceTree = ""; }; - DD0000012DUMMYID001234567 /* PiggyCards */ = { + DD0000012DUMMYID001234567 /* DashSpend/PiggyCards */ = { isa = PBXGroup; children = ( CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */, @@ -8682,9 +8672,9 @@ BA913BF91BD57E4D005A7C0E /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 51DFA7FB2FA8EAF2001EDB3A /* Components */, 51DC0001300000000000DC01 /* DashConnect */, 51DC0002300000000000DC02 /* DashConnect */, + 51DFA7FB2FA8EAF2001EDB3A /* Components */, ); name = dashwallet; packageProductDependencies = ( @@ -8788,9 +8778,9 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - 51DFA7FB2FA8EAF2001EDB3A /* Components */, 51DC0001300000000000DC01 /* DashConnect */, 51DC0002300000000000DC02 /* DashConnect */, + 51DFA7FB2FA8EAF2001EDB3A /* Components */, ); name = dashpay; packageProductDependencies = ( @@ -8894,7 +8884,7 @@ mainGroup = 75D5F3B5191EC270004AB296; packageReferences = ( 510336EB2FEBB1B4002E0899 /* XCRemoteSwiftPackageReference "DashUIKit" */, - 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */, + 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */, ); productRefGroup = 75D5F3BF191EC270004AB296 /* Products */; projectDirPath = ""; @@ -9052,7 +9042,7 @@ C9D2C9272A320AA000D15901 /* SyncView.xib in Resources */, C9D2C9292A320AA000D15901 /* BackupInfoItemView.xib in Resources */, C9D2C92A2A320AA000D15901 /* explore.db in Resources */, - C9D2C92B2A320AA000D15901 /* BuildFile in Resources */, + C9D2C92B2A320AA000D15901 /* (null) in Resources */, C9D2C92C2A320AA000D15901 /* Coinbase.storyboard in Resources */, 5151C8C62F913BF100F0A604 /* Coinbase-Info.plist in Resources */, 5151C8C72F913BF100F0A604 /* ZenLedger-Info.plist in Resources */, @@ -9476,7 +9466,7 @@ A11E57A7000BC0DE000BB00B /* SyncingAlertView.swift in Sources */, 11B8449628F5B9F80082770C /* CrowdNodeResponse.swift in Sources */, 47AE8BB228BFF61A00490F5E /* FileManager+DashWallet.swift in Sources */, - 7545ED532DA91A2F0075F45C /* CTXConstants.swift in Sources */, + 7545ED532DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */, 47AE8BF228C1306000490F5E /* PointOfUseItemCell.swift in Sources */, C9F42F9F29DA82E5001BC549 /* PayableViewController.swift in Sources */, 75FFD6C12BF493A60032879E /* HomeViewController+SecureWalletDelegateImpl.swift in Sources */, @@ -9493,7 +9483,7 @@ 2A0C69DA2314727F001B8C90 /* UISpringTimingParameters+DWInit.m in Sources */, 47C661AD28F972BD00028A8D /* NumberKeyboardButton.swift in Sources */, 11AE3DD82997C599000856EE /* IsDefaultEmail.swift in Sources */, - 75A2F3042DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */, + 75A2F3042DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */, 2A9FFE812230FF4700956D5F /* DWFormTableViewController.m in Sources */, C909614D29EFF7D600002D82 /* WalletKeysOverviewModel.swift in Sources */, 4751137528DAF28800223B77 /* UIAssembly.swift in Sources */, @@ -9837,10 +9827,10 @@ AA00301B2FD000000000001B /* SwapBuyTransactionMatcher.swift in Sources */, AA0030092FD0000000000009 /* SwapOrdersDAO.swift in Sources */, AA00300F2FD000000000000F /* SwapOrderMetadataProvider.swift in Sources */, - AA0000042DUMMYID001234567 /* GiftCardProvider.swift in Sources */, - BB0000012DUMMYID001234567 /* DashSpendError.swift in Sources */, - BB0000032DUMMYID001234567 /* DashSpendRepository.swift in Sources */, - BB0000052DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */, + AA0000042DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */, + BB0000012DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */, + BB0000032DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */, + BB0000052DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */, GG0000012DUMMYID001234567 /* GeoRestrictionService.swift in Sources */, CC0000112DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */, CC0000132DUMMYID001234567 /* PiggyCardsCache.swift in Sources */, @@ -9851,7 +9841,7 @@ 51A577482FBB3A6000FB4097 /* OrderPreviewViewModel.swift in Sources */, 5EAD111230A0000000A1B201 /* MayaAmountFormatter.swift in Sources */, EE0000042DUMMYID001234567 /* PercentageFormatter.swift in Sources */, - FF0000012DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */, + FF0000012DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */, FF0000052DUMMYID001234567 /* PiggyCardsModels.swift in Sources */, FF0000082DUMMYID001234567 /* BarcodeScanner.swift in Sources */, 2A8B9E6F2302A9C200FF8653 /* DWPasteboardAddressExtractor.m in Sources */, @@ -9869,12 +9859,12 @@ 2AD1CE8022DC92BF00C99324 /* NSString+DWTextSize.m in Sources */, 47AE8C0528C1F74A00490F5E /* PointOfUseListFiltersCell.swift in Sources */, 47AE8C1A28C6A21A00490F5E /* AllMerchantLocationsDataProvider.swift in Sources */, - 754565CC2DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */, + 754565CC2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */, 51F0D10430BF98BA00B27E11 /* ArrowDownIcon.swift in Sources */, 516983C22FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */, 51F0D10130BF97A900B27E11 /* ExclamationIcon.swift in Sources */, - 754565CE2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */, - 754565CF2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */, + 754565CE2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */, + 754565CF2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */, C9F451E52A0B986E00825057 /* MainTabbarController.swift in Sources */, 47B30D78290BFCA60080C326 /* NumberFormatter+DashWallet.swift in Sources */, 47AF18082907B7880025803E /* BaseAmountModel.swift in Sources */, @@ -10264,9 +10254,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 47976E4B2987781A00612988 /* DWAmountInputValidator.m in Sources */, 2A2120EC22145CAE009906DC /* DWAmountInputValidatorTests.m in Sources */, 471DD1C0290AECE900E030C8 /* String+DashWalletTests.swift in Sources */, + 0438F3D117619B14594F95E6 /* DashConnectUriTests.swift in Sources */, + 8D66A1B0E97F5B50411765B4 /* Secp256k1Tests.swift in Sources */, + AA03A0012FF3000000000001 /* KeyExchangeCryptoTests.swift in Sources */, + AA03A0022FF3000000000002 /* LoginKeyDerivationTests.swift in Sources */, + AA04A0012FF4000000000001 /* DashConnectDataSourceTests.swift in Sources */, + AA05A0012FF5000000000001 /* DashConnectStoreTests.swift in Sources */, + AA06A0012FF6000000000001 /* PlatformDashConnectDataSourceTests.swift in Sources */, 47976E4A2987772700612988 /* AmountObjectTests.swift in Sources */, A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */, B7C072AA26C7000000000001 /* DWContestedNameStatusServiceTests.swift in Sources */, @@ -10433,7 +10429,7 @@ 759AFDE32CC67E8E007072D2 /* VotingInfoScreen.swift in Sources */, C9D2C6EC2A320AA000D15901 /* DWModalInteractiveTransition.m in Sources */, C9D2C6F02A320AA000D15901 /* UIImage+Utils.m in Sources */, - 75A2F3032DE48C860046BE17 /* CTXSpendTokenService.swift in Sources */, + 75A2F3032DE48C860046BE17 /* DashSpend/CTX/CTXSpendTokenService.swift in Sources */, C9D2C6F22A320AA000D15901 /* DWSetupViewController.m in Sources */, C9D2C6F32A320AA000D15901 /* ExploreDatabaseConnection.swift in Sources */, C9D2C6F42A320AA000D15901 /* WithdrawalLimitsController.swift in Sources */, @@ -10505,7 +10501,7 @@ 75A8C1672AE5734A0042256E /* UsernameRequest.swift in Sources */, C9D2C70D2A320AA000D15901 /* CBAccount.swift in Sources */, C9D2C70E2A320AA000D15901 /* Types.swift in Sources */, - C9D2C70F2A320AA000D15901 /* BuildFile in Sources */, + C9D2C70F2A320AA000D15901 /* (null) in Sources */, C943B33B2A408CED00AF23C5 /* DWUploadAvatarChildView.m in Sources */, C9D2C7112A320AA000D15901 /* Coinbase.swift in Sources */, C9D2C7122A320AA000D15901 /* SyncModel.swift in Sources */, @@ -10532,7 +10528,7 @@ C9D2C7242A320AA000D15901 /* WKWebView+CrowdNode.swift in Sources */, C9D2C7252A320AA000D15901 /* SyncingActivityMonitor.swift in Sources */, C9D2C7262A320AA000D15901 /* EmptyUIView.swift in Sources */, - C9D2C7272A320AA000D15901 /* BuildFile in Sources */, + C9D2C7272A320AA000D15901 /* (null) in Sources */, C9D2C7282A320AA000D15901 /* DWQRScanModel.m in Sources */, C943B3292A408CED00AF23C5 /* DWExternalSourceViewController.m in Sources */, C9D2C72A2A320AA000D15901 /* SyncView.swift in Sources */, @@ -10598,7 +10594,7 @@ C956AF102A5B592E002FAB75 /* UIView+Dash.swift in Sources */, C9D2C74A2A320AA000D15901 /* ShortcutsView.swift in Sources */, C9D2C74B2A320AA000D15901 /* DWFormSectionModel.m in Sources */, - C9D2C74C2A320AA000D15901 /* BuildFile in Sources */, + C9D2C74C2A320AA000D15901 /* (null) in Sources */, C9D2C74D2A320AA000D15901 /* CoinbasePlaceBuyOrderResponse.swift in Sources */, C9D2C74E2A320AA000D15901 /* ExploreMapView.swift in Sources */, 40AB5A54235FDE2747ECE3F5 /* SwapConvertCardRow.swift in Sources */, @@ -10615,7 +10611,7 @@ C9D2C7552A320AA000D15901 /* DWPaymentInput.m in Sources */, C9D2C7572A320AA000D15901 /* OnlineAccountDetailsController.swift in Sources */, C9D2C7582A320AA000D15901 /* UIApplication+DashWallet.swift in Sources */, - 7545ED542DA91A2F0075F45C /* CTXConstants.swift in Sources */, + 7545ED542DA91A2F0075F45C /* DashSpend/CTXConstants.swift in Sources */, C9D2C7592A320AA000D15901 /* OnlineAccountInfoController.swift in Sources */, C9D2C75A2A320AA000D15901 /* PaymentController.swift in Sources */, C9D2C75B2A320AA000D15901 /* CALayer+MBAnimationPersistence.m in Sources */, @@ -10637,7 +10633,7 @@ AA0031032FD0000000000013 /* IconName+DashIconSource.swift in Sources */, 754C27C72CC3C0B900BA7B9F /* UsernamePrefs.swift in Sources */, C9D2C7672A320AA000D15901 /* CoinbaseExchangeRateResponse.swift in Sources */, - C9D2C7692A320AA000D15901 /* BuildFile in Sources */, + C9D2C7692A320AA000D15901 /* (null) in Sources */, C9D2C76A2A320AA000D15901 /* CrowdNodePortalItem.swift in Sources */, C9D2C76B2A320AA000D15901 /* BaseViewController.swift in Sources */, C9D2C76C2A320AA000D15901 /* AccountListController.swift in Sources */, @@ -10712,7 +10708,7 @@ C9D2C7A72A320AA000D15901 /* ActionButtonViewController.swift in Sources */, C9D2C7A82A320AA000D15901 /* BuySellServiceItemCell.swift in Sources */, C956AF122A5B5949002FAB75 /* PasteboardContentView.swift in Sources */, - C9D2C7AA2A320AA000D15901 /* BuildFile in Sources */, + C9D2C7AA2A320AA000D15901 /* (null) in Sources */, C9D2C7AB2A320AA000D15901 /* DWBaseFormTableViewCell.m in Sources */, C9D2C7AC2A320AA000D15901 /* AccountCell.swift in Sources */, 7566F4842BB6949E005238D2 /* ToolsMenuScreen.swift in Sources */, @@ -10721,7 +10717,7 @@ C943B3392A408CED00AF23C5 /* DWUploadAvatarViewController.m in Sources */, 759609242C455B2000F3BF04 /* SendIntro.swift in Sources */, C9D2C7B12A320AA000D15901 /* CoinbaseBaseIDForCurrencyResponse.swift in Sources */, - C9D2C7B22A320AA000D15901 /* BuildFile in Sources */, + C9D2C7B22A320AA000D15901 /* (null) in Sources */, C9D2C7B32A320AA000D15901 /* SpecifyAmountViewController.swift in Sources */, 756557B62CE84FFA0060348D /* FeeInfo.swift in Sources */, C943B5922A40ED7B00AF23C5 /* DWTextField.m in Sources */, @@ -10740,7 +10736,7 @@ C956AF212A5C33E5002FAB75 /* DWButton.m in Sources */, C9D2C7C02A320AA000D15901 /* HomeHeaderModel.swift in Sources */, C943B5942A40ED7B00AF23C5 /* DWPlanetarySystemView.m in Sources */, - C9D2C7C22A320AA000D15901 /* BuildFile in Sources */, + C9D2C7C22A320AA000D15901 /* (null) in Sources */, C9D2C7C32A320AA000D15901 /* DWDPRegistrationStatus.m in Sources */, C9D2C7C42A320AA000D15901 /* CurrencyExchanger_Objc.m in Sources */, 752F81982E30F55E00ADA76D /* SecurityMenuScreen.swift in Sources */, @@ -10754,7 +10750,7 @@ C9D2C7C92A320AA000D15901 /* BuyDashViewController.swift in Sources */, C956AF0D2A5B592E002FAB75 /* TappableLabel.swift in Sources */, C9D2C7CA2A320AA000D15901 /* DWModalDismissalAnimation.m in Sources */, - C9D2C7CB2A320AA000D15901 /* BuildFile in Sources */, + C9D2C7CB2A320AA000D15901 /* (null) in Sources */, C9D2C7CD2A320AA000D15901 /* UIViewController+DashWallet.swift in Sources */, 75D5D5332CC928630049ED7B /* UIHostingController+DashWallet.swift in Sources */, C9D2C7CE2A320AA000D15901 /* CrowdNodeCell.swift in Sources */, @@ -10785,10 +10781,10 @@ AA00301C2FD000000000001C /* SwapBuyTransactionMatcher.swift in Sources */, AA00300A2FD000000000000A /* SwapOrdersDAO.swift in Sources */, AA0030102FD0000000000010 /* SwapOrderMetadataProvider.swift in Sources */, - AA0000052DUMMYID001234567 /* GiftCardProvider.swift in Sources */, - BB0000022DUMMYID001234567 /* DashSpendError.swift in Sources */, - BB0000042DUMMYID001234567 /* DashSpendRepository.swift in Sources */, - BB0000062DUMMYID001234567 /* DashSpendRepositoryFactory.swift in Sources */, + AA0000052DUMMYID001234567 /* DashSpend/GiftCardProvider.swift in Sources */, + BB0000022DUMMYID001234567 /* DashSpend/DashSpendError.swift in Sources */, + BB0000042DUMMYID001234567 /* DashSpend/DashSpendRepository.swift in Sources */, + BB0000062DUMMYID001234567 /* DashSpend/DashSpendRepositoryFactory.swift in Sources */, GG0000022DUMMYID001234567 /* GeoRestrictionService.swift in Sources */, CC0000122DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */, CC0000142DUMMYID001234567 /* PiggyCardsCache.swift in Sources */, @@ -10797,7 +10793,7 @@ CC0000202DUMMYID001234567 /* PiggyCardsTokenService.swift in Sources */, EE0000022DUMMYID001234567 /* POIDetailsViewModel.swift in Sources */, EE0000062DUMMYID001234567 /* PercentageFormatter.swift in Sources */, - FF0000022DUMMYID001234567 /* PiggyCardsConstants.swift in Sources */, + FF0000022DUMMYID001234567 /* DashSpend/PiggyCardsConstants.swift in Sources */, FF0000072DUMMYID001234567 /* PiggyCardsModels.swift in Sources */, FF0000102DUMMYID001234567 /* BarcodeScanner.swift in Sources */, C9D2C7E32A320AA000D15901 /* PointOfUseListSegmentedCell.swift in Sources */, @@ -10914,7 +10910,7 @@ C9D2C8482A320AA000D15901 /* CoinbaseTransactionsRequest.swift in Sources */, C943B3372A408CED00AF23C5 /* DWEditProfileTextFieldCell.m in Sources */, C9D2C8492A320AA000D15901 /* DWAppRootViewController.m in Sources */, - C9D2C84A2A320AA000D15901 /* BuildFile in Sources */, + C9D2C84A2A320AA000D15901 /* (null) in Sources */, 7513DA882AB175E0005D55F6 /* TopperViewModel.swift in Sources */, C9D2C84B2A320AA000D15901 /* UITableView+DashWallet.swift in Sources */, 10853C84CC9043928BA2205A /* TransferAmountViewModel.swift in Sources */, @@ -10954,7 +10950,7 @@ C9D2C8622A320AA000D15901 /* BackupInfoViewController.swift in Sources */, C9D2C8652A320AA000D15901 /* DWCaptureSessionManager.m in Sources */, 7502A4872AE401EF00ACDDD3 /* UsernameVotingViewController.swift in Sources */, - C9D2C86A2A320AA000D15901 /* BuildFile in Sources */, + C9D2C86A2A320AA000D15901 /* (null) in Sources */, C9D2C86B2A320AA000D15901 /* AmountPreviewView.swift in Sources */, 75CDD7812C0898E400F433D2 /* Shape.swift in Sources */, 756FE7082CDCBBC800E6C195 /* UsernameValidationRuleResult.swift in Sources */, @@ -11006,7 +11002,7 @@ 75CDD7872C08D61300F433D2 /* DashAmount.swift in Sources */, C9D2C8972A320AA000D15901 /* RatesProvider.swift in Sources */, C9D2C8982A320AA000D15901 /* CBAuth.swift in Sources */, - C9D2C8992A320AA000D15901 /* BuildFile in Sources */, + C9D2C8992A320AA000D15901 /* (null) in Sources */, C9D2C89A2A320AA000D15901 /* CoinbaseCreateAddressesRequest.swift in Sources */, C943B3382A408CED00AF23C5 /* DWEditProfileAvatarView.m in Sources */, C943B5582A40DA3700AF23C5 /* DWFullScreenModalControllerViewController.m in Sources */, @@ -11037,9 +11033,9 @@ 757422222DF87B5200CB0175 /* MerchantFiltersView.swift in Sources */, C943B5382A40A65B00AF23C5 /* DWScrollingViewController.m in Sources */, C9D2C8B22A320AA000D15901 /* CoinbaseAccountAddress.swift in Sources */, - 754565C82DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */, - 754565CA2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */, - 754565CB2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */, + 754565C82DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendAPI.swift in Sources */, + 754565CA2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendRepository.swift in Sources */, + 754565CB2DAA52A000DA4E8E /* DashSpend/CTX/CTXSpendEndpoint.swift in Sources */, C930784E2A6AD59E00906E4B /* ConfirmPaymentModel.swift in Sources */, C9D2C8B42A320AA000D15901 /* CrowdNodeTransferViewController.swift in Sources */, C9D2C8B62A320AA000D15901 /* CBAuthInterop.swift in Sources */, @@ -11081,7 +11077,7 @@ C9D2C8DD2A320AA000D15901 /* AtmDataProvider.swift in Sources */, C9D2C8DE2A320AA000D15901 /* GettingStartedViewController.swift in Sources */, C9D2C8DF2A320AA000D15901 /* NetworkUnavailableView.swift in Sources */, - C9D2C8E02A320AA000D15901 /* BuildFile in Sources */, + C9D2C8E02A320AA000D15901 /* (null) in Sources */, 751B61C32ADFF9AE00D1C2EF /* UpholdPortalModel.swift in Sources */, C9D2C8E12A320AA000D15901 /* DWLockScreenViewController.m in Sources */, C9D2C8E22A320AA000D15901 /* UpholdTransferViewController.swift in Sources */, @@ -11271,7 +11267,7 @@ }; 75D5F3EB191EC270004AB296 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 75D5F3BD191EC270004AB296 /* dashwallet */; + target = C9D2C68A2A320AA000D15901 /* dashpay */; targetProxy = 75D5F3EA191EC270004AB296 /* PBXContainerItemProxy */; }; BA913BED1BD57E4D005A7C0E /* PBXTargetDependency */ = { @@ -11937,7 +11933,7 @@ baseConfigurationReference = 41D6F206BA803FDFA21B31F5 /* Pods-DashWalletTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -11972,7 +11968,7 @@ baseConfigurationReference = 1AF20341A5E313C9D309BF39 /* Pods-DashWalletTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -12951,7 +12947,7 @@ baseConfigurationReference = F5121ED6CEEEAEDBE95414EE /* Pods-DashWalletTests.testflight.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -13263,7 +13259,7 @@ baseConfigurationReference = 5CBC0F417B9E8DE631F9DE2A /* Pods-DashWalletTests.testnet.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashpay.app/dashpay"; CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -13436,7 +13432,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */ = { + 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../platform/packages/swift-sdk"; }; @@ -13456,12 +13452,12 @@ /* Begin XCSwiftPackageProductDependency section */ 3A1C87FAE41649975F6D9814 /* SwiftDashSDK */ = { isa = XCSwiftPackageProductDependency; - package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */; + package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */; productName = SwiftDashSDK; }; 3AEF30CC2C8105771448004C /* SwiftDashSDK */ = { isa = XCSwiftPackageProductDependency; - package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "swift-sdk" */; + package = 3F660CFDC68A3BB11096E81F /* XCLocalSwiftPackageReference "../platform/packages/swift-sdk" */; productName = SwiftDashSDK; }; 510336EC2FEBB1B4002E0899 /* DashUIKit */ = { diff --git a/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme b/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme index 80f3f30f7..5f48f9b7e 100644 --- a/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme +++ b/DashWallet.xcodeproj/xcshareddata/xcschemes/dashwallet-dashpay.xcscheme @@ -50,26 +50,6 @@ ReferencedContainer = "container:DashWallet.xcodeproj"> - - - - - - - - |)FF2csiJ-)knOxXe|`7- zei)%oKA(?pb3TDUF+f-Z5`P}p0-y!~G#2K-P9~F02+6)}FR~6K5g;b!eJbXS+`0$H zYA&6q4D3<<5Tof6kcGF6(YBb(|v|dxoHw zQN*grEX3rKF{C|Jfk;Lnms_iY2ATC_6$uJz*4VPp`0O1GF3B{8ZOkx60U5&+UNecx zsZ=tI8+a5-_A)0}>BZJNAq=5O1n2(Z0)r+g7SG}ix?6+u&5h(ocwi1o_Re4t2=FMk zk@#z61LK^-uCM&(s@Xsb!Y9MSV3!IM=UUdSFd^$act8$IrRV-_o#R$rS=^i6p5OSE z*!$ID@rQQu4r1Rcx2*R!7sQ*HO1%v9V{_Cbrg*y??A2BBDIIKYA0FE^Bn({%6Vf7d zZdWJ>#>g;(a1Z&SohF0~ObawBo9nN>jQknKP$--xxXBWF1?hN@SUHwJY&AMY=Br@agE}>X@82KdlfX7%iOxry{`7t8cZy198$c|ft|s*Nso3%Ik^tA zUeC=gGc9f+pDUEUFZa8*pa9+A$SOwnLdp3bOq|TMWJb0?rRDQ=@SsV!ZJk?4gK94- zJNOQKfDmYl^Hz;>-9jK1CO2z~3)>`jSXXbUS??|5p$; z&`@fo@4}I;bjN6mW84W>Y;sqto%HSnnz(MO5$5~K!eiqXXre97YrY`wlY?~+W-In M07*qoM6N<$f1d&3TPWco<$?)b@4U=gW6pf6@h&sZ2Km>3 zY#-zK&zqSy@4eYWXv6aIazU%1afL8ONTYx-2m~V+K7?>&UOO?r9BRY>l^4uTezp%i z#M1iKBO*PHfDeHCK{yCNg?FF8s(Jmy^y7#C4iG57`pMqR%uF@uZ(>qIK)Pgp9fK_J z7a&}nEfrrwmxu>FH&iqoe0ou&&OyTM!T_yIOiUbIdGB&#kRL9Q) z-hgAg5az)lim22yfLJ(;yg6_TFVZfpu3UaKer3nE4i%aPpk6lCpKQBl%0?r9_%S%5 z26`L>K%%%0$`5k_qBT(Du5-rM{1_xK%Z`~jA1XpU3SyxB&Bw5~xG`#T7rhB{C>S$F zASoObc(lMWzMmI!cVZNcDmFb3L=OxMf@Nwd6zzkVri04;PIM8!Ua&44k|#Dz8FOwQ zQ6Nl6HS1GVDYQNqH?|3VG9fOUkNHEejMgS3B#esVX?;?HRn-VW$`R(O*4y*Yvu?%h zAfPwOL~TW*wX>6x*4v9tPY*y4LUm}?xJXjC034vyspxW(*Z#iBYb7Qe$MeAfXSo3< zgK`zDPECCpsHv$5ZcxP3hx1SYM`+YLAOI5V^3T{EF_8sl9cm$PnS1cU|F+|7m18)0 z!#`u;rk_oiM%|kFP##`@LwLHXC|U)FAgRsf;Ga9d5qHdyPp2cjAd%^sp8k9j&~ufN zJb>um^MSdL9^`0n9JjZ2@W-k)R`8rVB*=1kYtFQ4hiu=$>`qL~|s4?(B?L^VrAgK%jp+g#VB!#b0T_4*wru@BWMD*yB zg{8I1k8quUWCV+(Iy+;8^+B=(_G(U^WbE+eUgGwWC4LhpGWJb*hv zLZ(fnn@y>NYZtx=vGxdhoTc^kM~R*Sr3;3p5-#-XiKh!LageM^$^`kPq_;GxRA=2( z!hPBB$r8k)!f_40m8eR}!s6NnmuV$dExhk*cfv6@L?IrUOx+K?WO+Q0T*8LnmD+qP zbpRp1O97idM)HZ2b4mk@ zOiirbQOgVXTzZ?x&8;l1iTya9E9}Jwtq9lTT?mIGDAY^-T)=J=djx3?^6juHTq|p9 zuO1RbCKQ$H+zRWupgo;X$c;M+y^ft!rE4dM{$aiT_KhvEE_@s4A>t`-e8B78yY6;N zH|d9!GSU|=BYojA(ibixec>|F7cL_`C7c(AZ7R}x%AxMAAICvPz8KfIHML z&W=mQx>Y5>bVWED_vO#%TA78oa4rG@Cepm{{Ml2{!X8oKTt-o(#S`5eT!x;^Iyu+4 zekp^Pa4YM7K6V|i!@T<|@=e5q(@>WPv5%F@e~H}q77;FK^z%2gzk&5>Du|{W4=;<2&1FEl*pU>unvH$=807*qoM6N<$f}Zh1nE(I) literal 0 HcmV?d00001 diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/dashconnect-empty@3x.png b/DashWallet/Resources/AppAssets.xcassets/DashConnect/dashconnect-empty.imageset/dashconnect-empty@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..dccedcdaafff7f62ceb9925dec16a297bc71b783 GIT binary patch literal 2518 zcmV;{2`To8P)IHM3!2#*U-)&3o)g+9H5gGv-lVh~Kva-^Ig1|xo=~~kLL<*1Z-oE)P&Y$!ZmBrMRsb%w_n~5~= zY8q2jc<8dYU+w*WS&>&uwV#Z@M7XnATmG)!$F3CBjyTXCUur5+0+l-0`1{RA;1K`Y z-=FbL;l6pCg^WPL<9-(IQcn9(>j1`k}Awe|YZVh7E@PuGfFPM>~yN7To= zU#}yu8Oae|N5a;>H#Rm}Ae8Rf`A^7`D}+_&zuDbAn$2BM#_Xo4GWjV8g-xzqn)1f< z)5|t|#(SUhKYmNp0K@wrhxxDe4wpe7b`8yNuhADiPFAPinuuqjve^~W&@PIoc5xKu z&Gz0mOOWBFC=R6c^1ihm5oe+zdP^VMwN z6W=lOS4F-*2f{Hg0dnt5dyElkdV8=5=C4YFCu%2mIhcDJ$J(Cw!OtexSl(-9O?9WS zkRt(V2l@#h93P)G%LIuwDLp*z&mTaJ5Xv^aJ@C|<$$|Df+#fg4WgcAx%;=w*fj!=qPKuS>y2yc|cdKZWya^G|U1xRi* zRzu!COHK@ajnGuCj6ff&KSPdaL?_%=39+Rp{}~XApwh~9@OkLSGUaueN>x;@GaxRi z-OhFJAt@)qM3AVdoM!ebE3^tcx*+AD1|>Zc$XFSLPER#cy4blPQbFp!kE z8ZUT#EY<`QGu+6tRDk!!0CItL&GHo*j)LlB6uwmZG&v%B94!8QnkXX)nV zs;J$$>a-8U%~qbJwbiqj)iY6fwwmLOm|4LMIP?@s8EFG?LsV?(nAWnd_#E!wj%hA^M`badU#L`r$o7op}(K4)voC*iE)H`Z2mq!h3a#)RzqE?xZd% zld-p*SZoAD_}fm}ep#7_%J2$V}88k+`r#3a-BgGdEK)dt@%^ zdFu83`BSCmtjj6TwJ4*hBMC4s(yf2W?ynwJexl&lAM z00pCTJS!ljs0E}HwSbhO7LZcZ0#b@vKuS>yNGWOoDMc+HrKkm@6t#d9PE?z+K##`@ znM9nBR>(dHoWIbI@+nL$Duj9BO2k9G&a(+do)cgL5gT`J-xQlwH4e^-%7I5fx;Aw7 zlYS61iEw9noSwOu*U33iZH&q(d;(e2QSXYoSzC6>GtwzhAMbp99aZHY*}q7~r`UP< zr7Jm~oDp@{IGx1EzzZ-boY?)=>qm>Z3d$hNygY^bF6@@Yi^^+g2jd6srpzoL3hK5+yH^DB+a`a@|?TlOA~{ zw)D1GQKQV8a@SST;V}qB^f{HFa|jY4Xl~Y4#YV!37Io07R9TT^wWl-?Eh?{BDhzi( z5?l^*?S`y2T|oRb;d)evN6b!`%Z`9>oVfeNPU{N^_pC_Xc3NdelEj&yj-U#SKQm$?PoIB=f))5*x1Ll39(D6zO4l&pr% zpLCHZpOQxX)C*o8yC!Cj!c@E1-94Hm3a$mH7O+ - - - - - - - - diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json new file mode 100644 index 000000000..b78af8e5c --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "menu-connections.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "menu-connections@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "menu-connections@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png b/DashWallet/Resources/AppAssets.xcassets/DashConnect/menu-connections.imageset/menu-connections.png new file mode 100644 index 0000000000000000000000000000000000000000..1e0b8f5838f608cd45a9a8f6ae7898f37f9dd490 GIT binary patch literal 616 zcmV-u0+;=XP)!FT@C*7a%x}?8M>Bhm2qO&Nmj8bcOcm$V{sDPGH6y z-lBq_GT%J#l7-`%l~iYoYimr(4(upHmg_QB_*J(*NxwO^_$rsyRhpHD5l$cZ( z3O`&PO`;v^(+oyNfi+Dv|=d8xM|L%rB#O~;$ebRHM z?ThYo!hgnxSEy_cfHcUnb7eviSrJZn+?8sOdhDsDa>4dP*0;`f)b_E-GrQ2l&G9g0 zrHn^S@e|C$pvNN5uRz=;)R-eE}?!-9S{WdbC<&d|kB}Hjd(-W^$>z>!eVuaj@ zH7p}@g+UfeWg~8J@0AGjsN6ucNY$6UMSuhTBfbC${HS~I&#glM0000|mGo@or0KEa6pm2hwCny=n z8aP17aDs*t04HcT0o)*N$xOHDICw*J%E~;nwpyR zg&BXdf4>9HKFJz85C~;Sd!dy(2o?e28i(%(D3kuLJ@8=OZ+oHNN!qXc2^#0MW8>^r)TpxL}2w+i-;m`!**o#=vhcpAYK!R}xSrOqCRiqm|BX7pq431U>qR;AW+jSHs1wm4n~{mlJ%n;bbf5TY4GgAfy*)S*OGm zRlV;(IDpIWm_!C@Js#SyE zb2v%0TC8+(!H#{O(b9_I;ha=!nh={LsaClz5=Egali!BW+lThmBYU=8`#gj}dGCl@`}eAOHn>4yUSRxShCZ46 zIf7PS+P7aqnO$qw5NI9_XKw|gZUJj=F_>ZeX@w577kctf2~$6{MvlOzvX2XVyE8>h zCV!nln`7J;)t{*q{GzSk?S#Jk`)e5e6S`Ab)4sB?@zy8siu z&~8+mBOF%j#bb;g-a-NrS=ZP0Rf%wn?cK;4Xa(nBXLL}J346{@oF6fjQbl5E`aLFe2Q6jKRlq?=r2)LPvrlw9FP+3>Cv(?`tr;ju=9#3gM8q z*M(EPU@V5}2GVF#AruAV?UfnP`gcgP^(vq^iwfeHm%c~(3ti(1k|@lAhDzyNbWTYa zn)nNS+N7W4<~@UF2#vuDNI~=Sseek2>)x?53to`X5^1esG36{+EDTQzp3sz4khmSS z9zz0}J|xbq>_W>N82k;38XN^3CE8rzMd;c_{ptR1btzDarr(9aOCDkT{;?mpa{@JPd5%O#M`%?{ z5E=?vC6W+MLui*{EFptjhXV#_2u($YatuX!fiUS`XmaJ+HNf&mq8A901V;3h{AMG{ z_t@9dztDAz;z#A(S3`>ag^pAF*n1^w0#iqna^5WT$3hl55(x+$w4=c)daozxUudI~ zYWE;T|3VM8<=Z_NBj1_f1N1Mn3u6k8Y9-s5!GlC%9VUEdgZogU?Vd?PXxUyM(-eaL zX$VcVa}xbJ2uf)sKstW(U~wGWJD^hGL{eFaxZPOUzl-IRfk~GT=TnB=) zR)SM;xw0Y${z7I~(youD%!sl<`=SYRQedcjtMTgnpB({S_STvrlgVT { get } - func parseQR(_ content: String) async throws -> ConnectionRequest - func approve(_ request: ConnectionRequest) async throws -> DAppConnection - func disconnect(id: String) async + func parseQR(_ content: String) async throws -> DashConnectQr + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection + func completeKeyRegistration(_ request: DashStRequest) async throws func remove(id: String) async } -enum DashConnectMockError: LocalizedError { +enum DashConnectMockError: LocalizedError, Equatable { case parseFailed case approveFailed + case notDashConnectQrCode + case unsupportedNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) + case keyRegistrationNotSupported var errorDescription: String? { switch self { @@ -39,18 +43,43 @@ enum DashConnectMockError: LocalizedError { return "Invalid QR payload" case .approveFailed: return "Approval failed" + case .notDashConnectQrCode: + return "This QR code is not a DashConnect QR code." + case let .unsupportedNetwork(expected, actual): + return "This DashConnect QR is for \(Self.displayName(for: actual)), but this wallet currently supports \(Self.displayName(for: expected)) only." + case .keyRegistrationNotSupported: + return "Key registration is not supported by the mock." + } + } + + private static func displayName(for network: DashConnectNetwork) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" } } } final class MockDashConnectDataSource: DashConnectDataSource { - static let sampleRequest = ConnectionRequest( - appLabel: "Yappr", - appUrl: "yap.pr", - appContractId: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", - walletUsername: "john.doe", - walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8" + static let sampleLoginRequest = DashKeyRequest( + appEphemeralPubKey: Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")!, + // Yappr's real testnet contract, so the sample resolves to Yappr branding and + // matches `sample(_:)`'s id. + contractId: Data([ + 0xc8, 0xb1, 0x02, 0xb5, 0x6e, 0xa7, 0xbb, 0xac, + 0xf7, 0x72, 0xd8, 0x36, 0x23, 0xf8, 0xd1, 0x8c, + 0x53, 0xae, 0xf2, 0x07, 0x66, 0x59, 0x9a, 0xc6, + 0x82, 0xb6, 0xa0, 0x19, 0xf2, 0x9d, 0x35, 0x3e + ]), + label: "Login to Yappr", + network: .testnet ) + static let sampleLoginQRCode = DashConnectSampleQR.keyUri(for: sampleLoginRequest) + static let sampleRequest = ConnectionRequest(loginRequest: sampleLoginRequest) static func sample(_ status: ConnectionStatus) -> DAppConnection { DAppConnection( @@ -73,13 +102,6 @@ final class MockDashConnectDataSource: DashConnectDataSource { status: .active, updatedAt: Date(timeIntervalSince1970: 1_773_218_700) ), - DAppConnection( - id: "AaC695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg09Z", - name: "PayKit", - url: "paykit.dev", - status: .disconnected, - updatedAt: Date(timeIntervalSince1970: 1_773_305_100) - ), ]) } #endif @@ -87,29 +109,53 @@ final class MockDashConnectDataSource: DashConnectDataSource { var shouldFailNextParse = false var shouldFailNextApprove = false + private let supportedNetwork: DashConnectNetwork + private let store: any DashConnectStore private let subject: CurrentValueSubject<[DAppConnection], Never> - init(initial: [DAppConnection] = []) { - self.subject = CurrentValueSubject(initial) + init( + initial: [DAppConnection] = [], + supportedNetwork: DashConnectNetwork = .testnet, + store: (any DashConnectStore)? = nil + ) { + self.supportedNetwork = supportedNetwork + self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) + let restored = initial.isEmpty ? self.store.load() : initial + self.subject = CurrentValueSubject(restored) } var connections: AnyPublisher<[DAppConnection], Never> { subject.eraseToAnyPublisher() } - func parseQR(_ content: String) async throws -> ConnectionRequest { - _ = content - try await Task.sleep(nanoseconds: 400_000_000) + var connectionsSnapshot: [DAppConnection] { + subject.value + } + func parseQR(_ content: String) async throws -> DashConnectQr { if shouldFailNextParse { shouldFailNextParse = false throw DashConnectMockError.parseFailed } - return Self.sampleRequest + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if DashConnectUri.isKeyUri(trimmed) { + let request = try DashConnectUri.parseKeyRequest(trimmed) + try validateNetwork(request.network) + return .login(request) + } + + if DashConnectUri.isStUri(trimmed) { + let request = try DashConnectUri.parseStRequest(trimmed) + try validateNetwork(request.network) + return .keyRegistration(request) + } + + throw DashConnectMockError.notDashConnectQrCode } - func approve(_ request: ConnectionRequest) async throws -> DAppConnection { + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { try await Task.sleep(nanoseconds: 400_000_000) if shouldFailNextApprove { @@ -117,36 +163,101 @@ final class MockDashConnectDataSource: DashConnectDataSource { throw DashConnectMockError.approveFailed } + let connectionRequest = await makeConnectionRequest(from: request) let connection = DAppConnection( - id: request.appContractId, - name: request.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : request.appLabel, - url: request.appUrl, + id: connectionRequest.appContractId, + name: connectionRequest.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : connectionRequest.appLabel, + url: connectionRequest.appUrl, status: .approved, updatedAt: Date() ) var current = subject.value.filter { $0.id != connection.id } current.append(connection) - subject.send(current.sorted(by: { $0.updatedAt > $1.updatedAt })) + persistAndSend(current) return connection } - func disconnect(id: String) async { - subject.send( - subject.value.map { connection in - guard connection.id == id else { return connection } - return DAppConnection( - id: connection.id, - name: connection.name, - url: connection.url, - status: .disconnected, - updatedAt: Date() - ) - } + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { + let fallback = ConnectionRequest(loginRequest: loginRequest) + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: fallback.appLabel, + appUrl: fallback.appUrl, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: existingConnection(for: fallback.appContractId) ) } + func completeKeyRegistration(_ request: DashStRequest) async throws { + try await Task.sleep(nanoseconds: 400_000_000) + throw DashConnectMockError.keyRegistrationNotSupported + } + func remove(id: String) async { - subject.send(subject.value.filter { $0.id != id }) + persistAndSend(subject.value.filter { $0.id != id }) + } + + private func validateNetwork(_ network: DashConnectNetwork) throws { + guard network == supportedNetwork else { + throw DashConnectMockError.unsupportedNetwork(expected: supportedNetwork, actual: network) + } + } + + private func persistAndSend(_ connections: [DAppConnection]) { + let sorted = connections.sorted(by: { $0.updatedAt > $1.updatedAt }) + store.save(sorted) + subject.send(sorted) + } + + private func existingConnection(for contractId: String) -> DAppConnection? { + subject.value.first { $0.id == contractId } + } +} + +private enum DashConnectSampleQR { + static func keyUri(for request: DashKeyRequest) -> String { + let labelData = Data(request.label.utf8) + let payload = Data([0x01]) + + request.appEphemeralPubKey + + request.contractId + + Data([UInt8(labelData.count)]) + + labelData + return "dash-key:\(base58Encode(payload))?n=\(request.network.rawValue)&v=1" + } + + private static func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) } } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift index ae0309b27..0b83da170 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -19,11 +19,32 @@ import Foundation +enum DashConnectQr: Equatable { + case login(DashKeyRequest) + case keyRegistration(DashStRequest) +} + /// Lifecycle of an app connection. enum ConnectionStatus: String, Codable, CaseIterable { case approved case active - case disconnected + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard let status = ConnectionStatus(rawValue: rawValue) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown DashConnect status: \(rawValue)" + ) + } + self = status + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } /// A connected app identified by its stable contract id. @@ -40,6 +61,45 @@ struct ConnectionRequest: Equatable { let appLabel: String let appUrl: String let appContractId: String - let walletUsername: String - let walletIdentityId: String + let walletUsername: String? + let walletIdentityId: String? + let existingConnection: DAppConnection? +} + +extension ConnectionRequest { + /// Declaring an initializer inside the struct body would suppress the memberwise one, + /// so this convenience lives in an extension. + init(loginRequest: DashKeyRequest) { + let contractId = (loginRequest.contractId as NSData).base58String() + let branding = DashConnectFallbackAppMetadata.resolve( + contractId: contractId, + unauthenticatedLabel: loginRequest.label + ) + self.init( + appLabel: branding.name, + appUrl: branding.url, + appContractId: contractId, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: nil + ) + } + + init( + loginRequest: DashKeyRequest, + appLabel: String, + appUrl: String, + walletUsername: String?, + walletIdentityId: String?, + existingConnection: DAppConnection? + ) { + self.init( + appLabel: appLabel.trimmingCharacters(in: .whitespacesAndNewlines), + appUrl: appUrl.trimmingCharacters(in: .whitespacesAndNewlines), + appContractId: (loginRequest.contractId as NSData).base58String(), + walletUsername: walletUsername, + walletIdentityId: walletIdentityId, + existingConnection: existingConnection + ) + } } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift new file mode 100644 index 000000000..5c7f2d59f --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/DashConnectStore.swift @@ -0,0 +1,174 @@ +// +// DashConnectStore.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol DashConnectStore { + func load() -> [DAppConnection] + func save(_ connections: [DAppConnection]) +} + +final class UserDefaultsDashConnectStore: DashConnectStore { + private let defaults: UserDefaults + private let network: DashConnectNetwork + private let walletIdHexProvider: () -> String? + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init( + defaults: UserDefaults = .standard, + network: DashConnectNetwork, + walletIdHexProvider: @escaping () -> String? = { + WalletEnvironment.activeWalletIdHex as String? + } + ) { + self.defaults = defaults + self.network = network + self.walletIdHexProvider = walletIdHexProvider + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + self.decoder = decoder + } + + var storageKey: String { + let walletScope = walletIdHexProvider()?.trimmingCharacters(in: .whitespacesAndNewlines) + let walletSuffix = (walletScope?.isEmpty == false) ? walletScope! : "no-wallet" + return "dashconnect.connections.v1.\(network.rawValue).\(walletSuffix)" + } + + func load() -> [DAppConnection] { + guard let data = defaults.data(forKey: storageKey) else { return [] } + guard let rawRows = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + logDrop("stored value is not a JSON array") + return [] + } + + var restored: [DAppConnection] = [] + restored.reserveCapacity(rawRows.count) + + for rawRow in rawRows { + guard JSONSerialization.isValidJSONObject(rawRow), + let rowData = try? JSONSerialization.data(withJSONObject: rawRow), + let row = try? decoder.decode(StoredConnection.self, from: rowData) else { + logDrop("row could not be decoded") + continue + } + + guard let contractIdBytes = Self.base58Decode(row.contractId), + contractIdBytes.count == 32 else { + logDrop("row has invalid contractId Base58") + continue + } + + restored.append( + DAppConnection( + id: row.contractId, + name: row.label, + url: row.url, + status: row.status, + updatedAt: row.updatedAt + ) + ) + } + + return restored + } + + func save(_ connections: [DAppConnection]) { + guard !connections.isEmpty else { + defaults.removeObject(forKey: storageKey) + return + } + + let rows = connections.map { + StoredConnection( + contractId: $0.id, + label: $0.name, + url: $0.url, + status: $0.status, + updatedAt: $0.updatedAt + ) + } + + do { + defaults.set(try encoder.encode(rows), forKey: storageKey) + } catch { + NSLog("DashConnectStore: failed to encode connections for %@: %@", storageKey, error.localizedDescription) + } + } + + private func logDrop(_ reason: String) { + NSLog("DashConnectStore: dropping stored row for %@: %@", storageKey, reason) + } + + private struct StoredConnection: Codable { + let contractId: String + let label: String + let url: String + let status: ConnectionStatus + let updatedAt: Date + } + + private static func base58Decode(_ string: String) -> Data? { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let input = Array(string.utf8) + guard !input.isEmpty else { return nil } + + var reverse = [Int8](repeating: -1, count: 128) + for (index, byte) in alphabet.enumerated() { + reverse[Int(byte)] = Int8(index) + } + + var leadingZeros = 0 + while leadingZeros < input.count, input[leadingZeros] == alphabet[0] { + leadingZeros += 1 + } + + let size = (input.count - leadingZeros) * 733 / 1000 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< input.count { + let byte = input[i] + guard byte < 128 else { return nil } + let digit = reverse[Int(byte)] + guard digit >= 0 else { return nil } + + var carry = Int(digit) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 58 * Int(buffer[j]) + buffer[j] = UInt8(carry % 256) + carry /= 256 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = Data(repeating: 0, count: leadingZeros) + result.append(contentsOf: buffer[start...]) + return result + } +} diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift new file mode 100644 index 000000000..136f446f0 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -0,0 +1,1086 @@ +// +// PlatformDashConnectDataSource.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import OSLog +import Security +import SwiftData +import SwiftDashSDK + +struct DashConnectAppMetadata: Equatable { + let name: String + let url: String +} + +enum DashConnectFallbackAppMetadata { + // Fallback-only branding keyed by the trustworthy contract id, not the spoofable QR label. + private static let knownApps: [String: DashConnectAppMetadata] = [ + "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F": .init(name: "Yappr", url: "yap.pr") + ] + + static func resolve(contractId: String, unauthenticatedLabel: String) -> DashConnectAppMetadata { + if let known = knownApps[contractId] { + return known + } + + return DashConnectAppMetadata( + name: unauthenticatedLabel.trimmingCharacters(in: .whitespacesAndNewlines), + url: "" + ) + } +} + +/// `Equatable` so tests can assert which failure occurred rather than matching +/// on its message. Matches `DashConnectMockError`, which is already declared +/// the same way; every associated value here is itself `Equatable`, so the +/// conformance is synthesised. +enum DashConnectPlatformError: LocalizedError, Equatable { + case noWallet + case noIdentity + case noModelContainer + case noSDK + case unsupportedRuntimeNetwork(Network) + case unsupportedRequestNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) + case authorizationCancelled + case authorizationFailed + case randomGenerationFailed(OSStatus) + case invalidHash160 + case noAuthenticationKey + case existingDocumentLookupFailed + case noApprovedConnectionAwaitingKeyRegistration + case keyRegistrationWrongIdentity + case keyRegistrationUnexpectedMutation + case keyRegistrationMismatchedDerivedKey(KeyPurpose) + + var errorDescription: String? { + switch self { + case .noWallet: + return "DashConnect requires an active SwiftDashSDK wallet." + case .noIdentity: + return "DashConnect requires a registered Platform identity." + case .noModelContainer: + return "DashConnect requires the SwiftData model container." + case .noSDK: + return "DashConnect requires the SwiftDashSDK runtime." + case .unsupportedRuntimeNetwork(let network): + return "DashConnect Platform publish is testnet-only right now, but the app runtime is on \(Self.displayName(for: network))." + case let .unsupportedRequestNetwork(expected, actual): + return "This DashConnect QR is for \(Self.displayName(for: actual)), but this wallet currently supports \(Self.displayName(for: expected)) only." + case .authorizationCancelled: + return NSLocalizedString("Authentication cancelled", comment: "DashConnect") + case .authorizationFailed: + return NSLocalizedString("Authentication failed", comment: "DashConnect") + case .randomGenerationFailed(let status): + return "Failed to generate secure random bytes for DashConnect (\(status))." + case .invalidHash160: + return "Failed to compute the app ephemeral public key hash." + case .noAuthenticationKey: + return "No enabled AUTHENTICATION ECDSA key is available for this identity." + case .existingDocumentLookupFailed: + return "The existing loginKeyResponse document could not be located for replacement." + case .noApprovedConnectionAwaitingKeyRegistration: + return "There is no login awaiting key registration — scan the app's login QR first." + case .keyRegistrationWrongIdentity: + return "The scanned key-registration transition targets a different identity." + case .keyRegistrationUnexpectedMutation: + return "The scanned key-registration transition does more than add the expected login keys." + case .keyRegistrationMismatchedDerivedKey(let purpose): + return "The scanned key-registration transition adds a \(purpose.name) key we did not derive." + } + } + + private static func displayName(for network: DashConnectNetwork) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" + } + } + + private static func displayName(for network: Network) -> String { + switch network { + case .mainnet: + return "Mainnet" + case .testnet: + return "Testnet" + case .devnet: + return "Devnet" + case .regtest: + return "Regtest" + } + } +} + +struct DashConnectLoginKeyResponseDraft: Equatable { + let properties: [String: AnyHashable] + let walletEphemeralPublicKey: Data + let encryptedPayload: Data +} + +struct DashConnectKeyRegistrationKey: Equatable { + let keyId: UInt32 + let keyType: KeyType + let purpose: KeyPurpose + let securityLevel: SecurityLevel + let publicKeyData: Data + let contractBounds: ContractBounds? +} + +struct DashConnectKeyRegistrationTransition: Equatable { + let identityId: Data + let addPublicKeys: [DashConnectKeyRegistrationKey] + let disablePublicKeyIds: [UInt32] +} + +/// The two keys a `dash-st:` transition was allowed to add, once every check +/// passed, paired with the app contract id they were derived against. +struct DashConnectKeyRegistrationValidationResult: Equatable { + let appContractId: Data + let authenticationKey: DashConnectKeyRegistrationKey + let encryptionKey: DashConnectKeyRegistrationKey +} + +private struct DashConnectKeyRegistrationDerivedMaterial { + var loginKey: Data + var authenticationPrivateKey: Data + let authenticationPublicKeyHash160: Data + var encryptionPrivateKey: Data + let encryptionPublicKey: Data +} + +protocol DashConnectKeyRegistrationParsing { + func parse(_ transitionBytes: Data) throws -> DashConnectKeyRegistrationTransition +} + +struct PlatformWalletDashConnectKeyRegistrationParser: DashConnectKeyRegistrationParsing { + private let parseTransition: (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition + + init( + parseTransition: @escaping (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition = { bytes in + try MainActor.assumeIsolated { + guard let wallet = SwiftDashSDKHost.shared.wallet else { + throw DashConnectPlatformError.noWallet + } + return try wallet.parseIdentityUpdateTransition(bytes) + } + } + ) { + self.parseTransition = parseTransition + } + + func parse(_ transitionBytes: Data) throws -> DashConnectKeyRegistrationTransition { + let parsed = try parseTransition(transitionBytes) + + return DashConnectKeyRegistrationTransition( + identityId: parsed.identityId, + addPublicKeys: parsed.addPublicKeys.map { key in + DashConnectKeyRegistrationKey( + keyId: key.keyId, + keyType: key.keyType, + purpose: key.purpose, + securityLevel: key.securityLevel, + publicKeyData: key.pubkeyBytes, + contractBounds: key.contractBounds.map { + switch $0 { + case .singleContract(let id): + return .singleContract(id: id) + case .singleContractDocumentType(let id, let documentTypeName): + return .singleContractDocumentType( + id: id, + documentTypeName: documentTypeName + ) + } + } + ) + }, + disablePublicKeyIds: parsed.disablePublicKeyIds + ) + } +} + +final class PlatformDashConnectDataSource: DashConnectDataSource { + struct DocumentSigningKeyCandidate: Equatable { + let keyId: UInt32 + let purpose: KeyPurpose + let securityLevel: SecurityLevel + let keyType: KeyType + let disabledAt: Int64? + } + + private struct Context { + let wallet: ManagedPlatformWallet + let sdk: SDK + let modelContainer: ModelContainer + let network: Network + let identityId: Data + let identityIndex: UInt32 + let storedUsername: String? + } + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "dashconnect.platform-data-source") + + static let loginKeyExchangeContractId: Data = { + guard let data = Data.identifier(fromBase58: "7UaqHGBJBbRLJ4fUWS45cnud8PPUugJWoGTt1SKwHJ2P"), + data.count == 32 else { + fatalError("DashConnect loginKeyResponse contract id must be a 32-byte identifier.") + } + return data + }() + static let loginKeyExchangeDocumentType = "loginKeyResponse" + + private let supportedNetwork: DashConnectNetwork + private let store: any DashConnectStore + private let subject: CurrentValueSubject<[DAppConnection], Never> + private let authorizer: DWIdentityAuthorizer + private let keyRegistrationParser: any DashConnectKeyRegistrationParsing + private let now: () -> Date + + init( + supportedNetwork: DashConnectNetwork = .testnet, + store: (any DashConnectStore)? = nil, + authorizer: DWIdentityAuthorizer = DWIdentityAuthorizer(), + keyRegistrationParser: any DashConnectKeyRegistrationParsing = PlatformWalletDashConnectKeyRegistrationParser(), + now: @escaping () -> Date = Date.init + ) { + assert(supportedNetwork == .testnet, "DashConnect Platform publish is currently testnet-only.") + self.supportedNetwork = supportedNetwork + self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) + self.authorizer = authorizer + self.keyRegistrationParser = keyRegistrationParser + self.now = now + self.subject = CurrentValueSubject(self.store.load()) + } + + var connections: AnyPublisher<[DAppConnection], Never> { + subject.eraseToAnyPublisher() + } + + func parseQR(_ content: String) async throws -> DashConnectQr { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if DashConnectUri.isKeyUri(trimmed) { + let request = try DashConnectUri.parseKeyRequest(trimmed) + try validateNetwork(request.network) + return .login(request) + } + + if DashConnectUri.isStUri(trimmed) { + let request = try DashConnectUri.parseStRequest(trimmed) + try validateNetwork(request.network) + return .keyRegistration(request) + } + + throw DashConnectMockError.notDashConnectQrCode + } + + func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest { + let fallback = ConnectionRequest(loginRequest: loginRequest) + let existingConnection = existingConnection(for: fallback.appContractId) + + guard let context = try? await requireContext() else { + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: fallback.appLabel, + appUrl: fallback.appUrl, + walletUsername: nil, + walletIdentityId: nil, + existingConnection: existingConnection + ) + } + + let metadata = await resolveAppMetadata(for: loginRequest, sdk: context.sdk) + let walletUsername = await resolveWalletUsername(context: context) + + return ConnectionRequest( + loginRequest: loginRequest, + appLabel: metadata.name.isEmpty ? fallback.appLabel : metadata.name, + appUrl: metadata.url, + walletUsername: walletUsername, + walletIdentityId: context.identityId.toBase58String(), + existingConnection: existingConnection + ) + } + + func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection { + Self.logger.info("🔗 DASHCONNECT :: approveLogin started") + try validateNetwork(request.network) + + let context: Context + do { + context = try await requireContext() + } catch { + Self.logger.error("🔗 DASHCONNECT :: no context — \(error.localizedDescription, privacy: .public)") + throw error + } + do { + try await authorize() + } catch { + Self.logger.error("🔗 DASHCONNECT :: authorization failed — \(error.localizedDescription, privacy: .public)") + throw error + } + + // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. + var chainKey = try await MainActor.run { + try context.wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: context.identityIndex, + keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), + network: context.network + ).privateKeyData + } + defer { Self.zero(&chainKey) } + + var loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: request.contractId + ) + defer { Self.zero(&loginKey) } + + var walletEphemeralPrivateKey = try Self.generateEphemeralPrivateKey() + let draft = try Self.buildLoginKeyResponseDraft( + loginKey: loginKey, + appContractId: request.contractId, + appEphemeralPubKey: request.appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey + ) + + let signer = KeychainSigner(modelContainer: context.modelContainer) + let propertiesJSON = try Self.makeJSONObjectString(from: draft.properties) + + do { + _ = try await context.wallet.createDocument( + ownerIdentityId: context.identityId, + contractId: Self.loginKeyExchangeContractId, + documentType: Self.loginKeyExchangeDocumentType, + propertiesJSON: propertiesJSON, + signer: signer + ) + } catch { + let errorText = String(describing: error).lowercased() + let shouldReplace = errorText.contains("duplicate unique") + || errorText.contains("already exists") + || errorText.contains("duplicate") + guard shouldReplace else { + throw error + } + + let existingDocumentId = try await findExistingLoginKeyResponseDocumentId( + ownerIdentityId: context.identityId, + appContractId: request.contractId, + sdk: context.sdk + ) + let signingKeyId = try selectDocumentSigningKeyId( + wallet: context.wallet, + identityId: context.identityId + ) + + _ = try await context.wallet.replaceDocument( + ownerIdentityId: context.identityId, + contractId: Self.loginKeyExchangeContractId, + documentType: Self.loginKeyExchangeDocumentType, + documentId: existingDocumentId, + propertiesJSON: propertiesJSON, + signingKeyId: signingKeyId, + signer: signer + ) + } + + let preview = await makeConnectionRequest(from: request) + let connection: DAppConnection + do { + var derivedMaterial = try Self.deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: request.contractId + ) + defer { + Self.zero(&derivedMaterial.loginKey) + Self.zero(&derivedMaterial.authenticationPrivateKey) + Self.zero(&derivedMaterial.encryptionPrivateKey) + } + + let currentPublicKeys = try context.wallet + .managedIdentity(identityId: context.identityId) + .getPublicKeys() + // The `dash-st:` registration happens once per (identity, app): + // after it, the app stops emitting that QR. So the status reflects + // whether the derived login keys are on the identity, not which + // step of the flow just ran. + if Self.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: derivedMaterial.authenticationPublicKeyHash160, + encryptionPublicKey: derivedMaterial.encryptionPublicKey, + currentIdentityPublicKeys: currentPublicKeys + ) { + connection = Self.makeConnection(preview: preview, status: .active, updatedAt: now()) + } else { + connection = Self.makeConnection(preview: preview, status: .approved, updatedAt: now()) + } + } catch { + Self.logger.error( + "🔗 DASHCONNECT :: could not confirm registered login keys after approveLogin — \(error.localizedDescription, privacy: .public)" + ) + // Conservative fallback: asking for a QR is safer than claiming the + // registration is already on the identity when we could not verify it. + connection = Self.makeConnection(preview: preview, status: .approved, updatedAt: now()) + } + + var current = subject.value.filter { $0.id != connection.id } + current.append(connection) + persistAndSend(current) + return connection + } + + func completeKeyRegistration(_ request: DashStRequest) async throws { + try validateNetwork(request.network) + let context = try await requireContext() + let pendingConnection = try pendingApprovedConnectionForKeyRegistration() + guard let pendingAppContractId = Self.decodeIdentifier(pendingConnection.id) else { + throw DashConnectPlatformError.noApprovedConnectionAwaitingKeyRegistration + } + // Chosen approach: (a) deserialize the scanned IdentityUpdateTransition, + // verify it only adds the exact derived login keys for our identity, + // then rebuild the equivalent `updateIdentity(...)` call through the SDK. + let transition = try await MainActor.run { + try keyRegistrationParser.parse(request.transitionBytes) + } + + // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. + var chainKey = try await MainActor.run { + try context.wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: context.identityIndex, + keyId: UInt32(LoginKeyDerivation.defaultKeyIndex), + network: context.network + ).privateKeyData + } + defer { Self.zero(&chainKey) } + + var derivedMaterial = try Self.deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKey, + identityId: context.identityId, + appContractId: pendingAppContractId + ) + defer { + Self.zero(&derivedMaterial.loginKey) + Self.zero(&derivedMaterial.authenticationPrivateKey) + Self.zero(&derivedMaterial.encryptionPrivateKey) + } + + let validated = try Self.validateKeyRegistration( + transition, + identityId: context.identityId, + appContractId: pendingAppContractId, + derivedMaterial: derivedMaterial + ) + + let currentPublicKeys = try context.wallet + .managedIdentity(identityId: context.identityId) + .getPublicKeys() + let missingKeys = Self.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentPublicKeys + ) + + guard !missingKeys.isEmpty else { + persistAndSend( + subject.value.map { connection in + guard connection.id == pendingConnection.id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .active, + updatedAt: now() + ) + } + ) + return + } + + try await authorize() + + let signer = KeychainSigner(modelContainer: context.modelContainer) + try await signer.withAdditionalSigningKeys([ + ( + publicKey: validated.authenticationKey.publicKeyData, + privateKey: derivedMaterial.authenticationPrivateKey + ), + ( + publicKey: validated.encryptionKey.publicKeyData, + privateKey: derivedMaterial.encryptionPrivateKey + ), + ]) { + try await context.wallet.updateIdentity( + identityId: context.identityId, + addPublicKeys: missingKeys.map(Self.makeManagedIdentityPubkey), + signer: signer + ) + } + + persistAndSend( + subject.value.map { connection in + guard connection.id == pendingConnection.id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .active, + updatedAt: now() + ) + } + ) + } + + func remove(id: String) async { + persistAndSend(subject.value.filter { $0.id != id }) + } + + static func buildLoginKeyResponseDraft( + loginKey: Data, + appContractId: Data, + appEphemeralPubKey: Data, + walletEphemeralPrivateKey: inout Data, + encryptLoginKey: (Data, Data, Data) throws -> Data = { loginKey, walletPriv, appPub in + try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletPriv, + appEphemeralPub: appPub + ) + } + ) throws -> DashConnectLoginKeyResponseDraft { + defer { zero(&walletEphemeralPrivateKey) } + + let appEphemeralPubKeyHash = KeyExchangeCrypto.hash160(appEphemeralPubKey) + guard appEphemeralPubKeyHash.count == 20 else { + throw DashConnectPlatformError.invalidHash160 + } + + let walletEphemeralPublicKey = try Secp256k1.compressedPublicKey(privateKey: walletEphemeralPrivateKey) + let encryptedPayload = try encryptLoginKey(loginKey, walletEphemeralPrivateKey, appEphemeralPubKey) + + return DashConnectLoginKeyResponseDraft( + properties: [ + "contractId": appContractId.toBase58String(), + "appEphemeralPubKeyHash": appEphemeralPubKeyHash.toHexString(), + "walletEphemeralPubKey": walletEphemeralPublicKey.toHexString(), + "encryptedPayload": encryptedPayload.toHexString(), + "keyIndex": LoginKeyDerivation.defaultKeyIndex, + ], + walletEphemeralPublicKey: walletEphemeralPublicKey, + encryptedPayload: encryptedPayload + ) + } + + static func validateKeyRegistration( + _ transition: DashConnectKeyRegistrationTransition, + chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data + ) throws -> DashConnectKeyRegistrationValidationResult { + var derivedMaterial = try deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: chainKeyPrivateBytes, + identityId: identityId, + appContractId: appContractId + ) + defer { + zero(&derivedMaterial.loginKey) + zero(&derivedMaterial.authenticationPrivateKey) + zero(&derivedMaterial.encryptionPrivateKey) + } + + return try validateKeyRegistration( + transition, + identityId: identityId, + appContractId: appContractId, + derivedMaterial: derivedMaterial + ) + } + + private static func deriveKeyRegistrationMaterial( + chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data + ) throws -> DashConnectKeyRegistrationDerivedMaterial { + var loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKeyPrivateBytes, + identityId: identityId, + appContractId: appContractId + ) + var authenticationPrivateKey = Data() + var encryptionPrivateKey = Data() + + do { + authenticationPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: loginKey, + identityId: identityId + ) + let authenticationPublicKey = try Secp256k1.compressedPublicKey( + privateKey: authenticationPrivateKey + ) + let authenticationPublicKeyHash160 = KeyExchangeCrypto.hash160(authenticationPublicKey) + + encryptionPrivateKey = try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: loginKey, + identityId: identityId + ) + let encryptionPublicKey = try Secp256k1.compressedPublicKey( + privateKey: encryptionPrivateKey + ) + + return DashConnectKeyRegistrationDerivedMaterial( + loginKey: loginKey, + authenticationPrivateKey: authenticationPrivateKey, + authenticationPublicKeyHash160: authenticationPublicKeyHash160, + encryptionPrivateKey: encryptionPrivateKey, + encryptionPublicKey: encryptionPublicKey + ) + } catch { + zero(&loginKey) + zero(&authenticationPrivateKey) + zero(&encryptionPrivateKey) + throw error + } + } + + private static func validateKeyRegistration( + _ transition: DashConnectKeyRegistrationTransition, + identityId: Data, + appContractId: Data, + derivedMaterial: DashConnectKeyRegistrationDerivedMaterial + ) throws -> DashConnectKeyRegistrationValidationResult { + guard transition.identityId == identityId else { + throw DashConnectPlatformError.keyRegistrationWrongIdentity + } + guard transition.disablePublicKeyIds.isEmpty else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + guard transition.addPublicKeys.count == 2 else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + + var authenticationKey: DashConnectKeyRegistrationKey? + var encryptionKey: DashConnectKeyRegistrationKey? + + for key in transition.addPublicKeys { + switch key.purpose { + case .authentication: + guard authenticationKey == nil, + key.keyType == .ecdsaHash160, + key.securityLevel == .high, + key.publicKeyData == derivedMaterial.authenticationPublicKeyHash160 else { + throw DashConnectPlatformError.keyRegistrationMismatchedDerivedKey(.authentication) + } + authenticationKey = key + case .encryption: + guard encryptionKey == nil, + key.keyType == .ecdsaSecp256k1, + key.securityLevel == .medium, + key.publicKeyData == derivedMaterial.encryptionPublicKey else { + throw DashConnectPlatformError.keyRegistrationMismatchedDerivedKey(.encryption) + } + encryptionKey = key + default: + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + } + + guard let authenticationKey, let encryptionKey else { + throw DashConnectPlatformError.keyRegistrationUnexpectedMutation + } + + return DashConnectKeyRegistrationValidationResult( + appContractId: appContractId, + authenticationKey: authenticationKey, + encryptionKey: encryptionKey + ) + } + + static func hasRegisteredLoginKeys( + authenticationPublicKeyHash160: Data, + encryptionPublicKey: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: authenticationPublicKeyHash160, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) && hasRegisteredEncryptionKey( + encryptionPublicKey: encryptionPublicKey, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + } + + static func missingKeyRegistrationKeys( + _ validated: DashConnectKeyRegistrationValidationResult, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> [DashConnectKeyRegistrationKey] { + let hasAuthenticationKey = hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + let hasEncryptionKey = hasRegisteredEncryptionKey( + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentIdentityPublicKeys + ) + + var missingKeys: [DashConnectKeyRegistrationKey] = [] + if !hasAuthenticationKey { + missingKeys.append(validated.authenticationKey) + } + if !hasEncryptionKey { + missingKeys.append(validated.encryptionKey) + } + return missingKeys + } + + private static func makeConnection( + preview: ConnectionRequest, + status: ConnectionStatus, + updatedAt: Date + ) -> DAppConnection { + DAppConnection( + id: preview.appContractId, + name: preview.appLabel.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : preview.appLabel, + url: preview.appUrl, + status: status, + updatedAt: updatedAt + ) + } + + private static func hasRegisteredAuthenticationKey( + authenticationPublicKeyHash160: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + currentIdentityPublicKeys.contains { + $0.purpose == .authentication + && $0.securityLevel == .high + && $0.keyType == .ecdsaHash160 + && $0.disabledAt == nil + && $0.data == authenticationPublicKeyHash160 + } + } + + private static func hasRegisteredEncryptionKey( + encryptionPublicKey: Data, + currentIdentityPublicKeys: [ManagedIdentity.IdentityPublicKeyInfo] + ) -> Bool { + currentIdentityPublicKeys.contains { + $0.purpose == .encryption + && $0.securityLevel == .medium + && $0.keyType == .ecdsaSecp256k1 + && $0.disabledAt == nil + && $0.data == encryptionPublicKey + } + } + + private func persistAndSend(_ connections: [DAppConnection]) { + let sorted = connections.sorted(by: { $0.updatedAt > $1.updatedAt }) + store.save(sorted) + subject.send(sorted) + } + + private func existingConnection(for contractId: String) -> DAppConnection? { + subject.value.first { $0.id == contractId } + } + + private func pendingApprovedConnectionForKeyRegistration() throws -> DAppConnection { + try Self.pendingApprovedConnectionForKeyRegistration(in: subject.value) + } + + static func pendingApprovedConnectionForKeyRegistration( + in connections: [DAppConnection] + ) throws -> DAppConnection { + guard let connection = connections + .filter({ $0.status == .approved }) + .max(by: { $0.updatedAt < $1.updatedAt }) else { + throw DashConnectPlatformError.noApprovedConnectionAwaitingKeyRegistration + } + return connection + } + + private func validateNetwork(_ network: DashConnectNetwork) throws { + guard network == supportedNetwork else { + throw DashConnectPlatformError.unsupportedRequestNetwork( + expected: supportedNetwork, + actual: network + ) + } + } + + private func authorize() async throws { + do { + try await authorizer.authorize() + } catch DWIdentityAuthorizer.AuthError.cancelled { + throw DashConnectPlatformError.authorizationCancelled + } catch { + throw DashConnectPlatformError.authorizationFailed + } + } + + private func requireContext() async throws -> Context { + let runtime = await MainActor.run { + ( + wallet: SwiftDashSDKHost.shared.wallet, + sdk: SwiftDashSDKHost.shared.sdk, + modelContainer: SwiftDashSDKHost.shared.modelContainer, + runningNetwork: SwiftDashSDKHost.shared.runningNetwork, + identityId: DWCurrentUserIdentityInfo.shared.identityId, + storedUsername: DWCurrentUserIdentityInfo.shared.username + ) + } + + guard let wallet = runtime.wallet else { + Self.logger.error("🔗 DASHCONNECT :: context missing: wallet") + throw DashConnectPlatformError.noWallet + } + guard let sdk = runtime.sdk else { + Self.logger.error("🔗 DASHCONNECT :: context missing: sdk") + throw DashConnectPlatformError.noSDK + } + guard let modelContainer = runtime.modelContainer else { + Self.logger.error("🔗 DASHCONNECT :: context missing: modelContainer") + throw DashConnectPlatformError.noModelContainer + } + guard let network = runtime.runningNetwork else { + Self.logger.error("🔗 DASHCONNECT :: context missing: runningNetwork") + throw DashConnectPlatformError.noWallet + } + guard network == .testnet else { + Self.logger.error("🔗 DASHCONNECT :: wallet is on \(String(describing: network), privacy: .public), DashConnect is testnet-only") + throw DashConnectPlatformError.unsupportedRuntimeNetwork(network) + } + guard let identityId = runtime.identityId else { + Self.logger.error("🔗 DASHCONNECT :: context missing: identityId — no registered DashPay identity on this wallet") + throw DashConnectPlatformError.noIdentity + } + + let identityIndex = try wallet.managedIdentity(identityId: identityId).getIdentityIndex() ?? 0 + + return Context( + wallet: wallet, + sdk: sdk, + modelContainer: modelContainer, + network: network, + identityId: identityId, + identityIndex: identityIndex, + storedUsername: runtime.storedUsername + ) + } + + private func resolveAppMetadata(for request: DashKeyRequest, sdk: SDK) async -> DashConnectAppMetadata { + let contractId = request.contractId.toBase58String() + let fallback = DashConnectFallbackAppMetadata.resolve( + contractId: contractId, + unauthenticatedLabel: request.label + ) + + do { + let contract = try await sdk.dataContractGet(id: contractId) + let ownerId = contract["ownerId"] as? String ?? contract["$ownerId"] as? String + + if let ownerId, + let usernames = try? await sdk.dpnsGetUsername(identityId: ownerId, limit: 1), + let label = usernames.first?["label"] as? String, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return DashConnectAppMetadata(name: label, url: fallback.url) + } + } catch { + Self.logger.error("🔗 DASHCONNECT :: app metadata resolution failed for \(contractId, privacy: .public): \(String(describing: error), privacy: .public)") + } + + return fallback + } + + private func resolveWalletUsername(context: Context) async -> String? { + let identityId = context.identityId.toBase58String() + + if let usernames = try? await context.sdk.dpnsGetUsername(identityId: identityId, limit: 1), + let label = usernames.first?["label"] as? String, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return label + } + + return context.storedUsername + } + + private func findExistingLoginKeyResponseDocumentId( + ownerIdentityId: Data, + appContractId: Data, + sdk: SDK + ) async throws -> Data { + let whereClause = """ + [["$ownerId","==","\(ownerIdentityId.toBase58String())"],["contractId","==","\(appContractId.toBase58String())"]] + """ + + let response = try await sdk.documentList( + dataContractId: Self.loginKeyExchangeContractId.toBase58String(), + documentType: Self.loginKeyExchangeDocumentType, + whereClause: whereClause, + limit: 1 + ) + + guard let documents = response["documents"] as? [[String: Any]], + let id = documents.first?["$id"] as? String, + let identifier = Self.decodeIdentifier(id), + identifier.count == 32 else { + throw DashConnectPlatformError.existingDocumentLookupFailed + } + + return identifier + } + + private func selectDocumentSigningKeyId( + wallet: ManagedPlatformWallet, + identityId: Data + ) throws -> UInt32 { + let keys = try wallet.managedIdentity(identityId: identityId) + .getPublicKeys() + .map { + DocumentSigningKeyCandidate( + keyId: UInt32(bitPattern: $0.keyId), + purpose: $0.purpose, + securityLevel: $0.securityLevel, + keyType: $0.keyType, + disabledAt: $0.disabledAt + ) + } + + return try Self.selectDocumentSigningKeyId(from: keys) + } + + static func selectDocumentSigningKeyId( + from keys: [DocumentSigningKeyCandidate] + ) throws -> UInt32 { + let eligible = keys + .filter { + $0.disabledAt == nil + && $0.purpose == .authentication + && $0.keyType == .ecdsaSecp256k1 + && ($0.securityLevel == .high || $0.securityLevel == .critical) + } + .sorted { + let lhsPriority = signingPriority(for: $0.securityLevel) + let rhsPriority = signingPriority(for: $1.securityLevel) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return $0.keyId < $1.keyId + } + + guard let key = eligible.first else { + throw DashConnectPlatformError.noAuthenticationKey + } + + return key.keyId + } + + private static func signingPriority(for securityLevel: SecurityLevel) -> Int { + switch securityLevel { + case .high: + return 0 + case .critical: + return 1 + case .master, .medium: + return 2 + } + } + + private static func makeManagedIdentityPubkey( + from key: DashConnectKeyRegistrationKey + ) -> ManagedPlatformWallet.IdentityPubkey { + ManagedPlatformWallet.IdentityPubkey( + keyId: key.keyId, + keyType: key.keyType, + purpose: key.purpose, + securityLevel: key.securityLevel, + pubkeyBytes: key.publicKeyData, + contractBounds: key.contractBounds.map { + switch $0 { + case .singleContract(let id): + return .singleContract(id: id) + case .singleContractDocumentType(let id, let documentTypeName): + return .singleContractDocumentType(id: id, documentTypeName: documentTypeName) + } + } + ) + } + + private static func generateEphemeralPrivateKey() throws -> Data { + while true { + var candidate = try randomBytes(count: 32) + if (try? Secp256k1.compressedPublicKey(privateKey: candidate)) != nil { + return candidate + } + zero(&candidate) + } + } + + private static func randomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + zero(&bytes) + throw DashConnectPlatformError.randomGenerationFailed(status) + } + let data = Data(bytes) + zero(&bytes) + return data + } + + private static func decodeIdentifier(_ string: String) -> Data? { + if let base58 = Data.identifier(fromBase58: string), base58.count == 32 { + return base58 + } + if let base64 = Data(base64Encoded: string), base64.count == 32 { + return base64 + } + return nil + } + + private static func makeJSONObjectString(from properties: [String: AnyHashable]) throws -> String { + let dictionary = Dictionary(uniqueKeysWithValues: properties.map { ($0.key, $0.value.base) }) + let data = try JSONSerialization.data(withJSONObject: dictionary, options: [.sortedKeys]) + guard let string = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return string + } + + static func zero(_ data: inout Data) { + data.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } + + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift new file mode 100644 index 000000000..5c3f6583e --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectRequests.swift @@ -0,0 +1,21 @@ +import Foundation + +enum DashConnectNetwork: String { + case mainnet = "m" + case testnet = "t" + case devnet = "d" +} + +struct DashKeyRequest: Equatable { + let appEphemeralPubKey: Data + let contractId: Data + /// Unauthenticated display label from the QR payload. This is spoofable and must + /// not be treated as a verified app identity. + let label: String + let network: DashConnectNetwork +} + +struct DashStRequest: Equatable { + let transitionBytes: Data + let network: DashConnectNetwork +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift new file mode 100644 index 000000000..7c781cd6a --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/DashConnectUri.swift @@ -0,0 +1,236 @@ +import Foundation + +enum DashConnectUriError: LocalizedError, Equatable { + case invalidKeyScheme + case invalidStScheme + case unexpectedAuthority + case missingQuery + case emptyBody + case missingNetwork + case missingVersion + case unsupportedVersion + case unknownNetwork + case invalidBase58 + case keyPayloadTooShort + case invalidKeyPayloadVersion + case keyLabelTooLong + case keyLabelLengthOverrunsPayload + case invalidEphemeralPublicKey + case emptyTransitionPayload + + var errorDescription: String? { + switch self { + case .invalidKeyScheme: + return "The URI does not use the dash-key scheme." + case .invalidStScheme: + return "The URI does not use the dash-st scheme." + case .unexpectedAuthority: + return "DashConnect URIs must not contain // authority separators." + case .missingQuery: + return "DashConnect URIs must include query parameters." + case .emptyBody: + return "DashConnect URIs must include a non-empty Base58 body." + case .missingNetwork: + return "DashConnect URIs must include the network query item." + case .missingVersion: + return "DashConnect URIs must include the version query item." + case .unsupportedVersion: + return "Only DashConnect URI version 1 is supported." + case .unknownNetwork: + return "The URI contains an unknown DashConnect network code." + case .invalidBase58: + return "The URI body is not valid Base58." + case .keyPayloadTooShort: + return "The dash-key payload is shorter than the minimum 67-byte format." + case .invalidKeyPayloadVersion: + return "The dash-key payload version byte is not supported." + case .keyLabelTooLong: + return "The dash-key payload label is longer than 64 bytes." + case .keyLabelLengthOverrunsPayload: + return "The dash-key payload label length overruns the payload." + case .invalidEphemeralPublicKey: + return "The dash-key payload contains an invalid compressed secp256k1 point." + case .emptyTransitionPayload: + return "The dash-st payload must be non-empty." + } + } +} + +enum DashConnectUri { + private static let keyScheme = "dash-key:" + private static let stScheme = "dash-st:" + private static let expectedVersion = "1" + private static let keyPayloadVersion: UInt8 = 0x01 + private static let compressedPubKeyLength = 33 + private static let contractIdLength = 32 + private static let minKeyPayloadLength = 1 + compressedPubKeyLength + contractIdLength + 1 + private static let maxLabelLength = 64 + private static let base58Alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + private static let base58Reverse: [Int8] = { + var map = [Int8](repeating: -1, count: 128) + for (index, byte) in base58Alphabet.enumerated() { + map[Int(byte)] = Int8(index) + } + return map + }() + + static func isKeyUri(_ uri: String) -> Bool { + uri.hasPrefix(keyScheme) + } + + static func isStUri(_ uri: String) -> Bool { + uri.hasPrefix(stScheme) + } + + static func parseKeyRequest(_ uri: String) throws -> DashKeyRequest { + let envelope = try parseEnvelope(uri, scheme: keyScheme, invalidScheme: .invalidKeyScheme) + let payload = envelope.payload + + guard payload.count >= minKeyPayloadLength else { + throw DashConnectUriError.keyPayloadTooShort + } + guard payload[payload.startIndex] == keyPayloadVersion else { + throw DashConnectUriError.invalidKeyPayloadVersion + } + + let pubKeyStart = payload.index(after: payload.startIndex) + let pubKeyEnd = payload.index(pubKeyStart, offsetBy: compressedPubKeyLength) + let contractEnd = payload.index(pubKeyEnd, offsetBy: contractIdLength) + let labelLengthIndex = contractEnd + let labelStart = payload.index(after: labelLengthIndex) + + let appEphemeralPubKey = Data(payload[pubKeyStart ..< pubKeyEnd]) + guard Secp256k1.isValidCompressedPoint(appEphemeralPubKey) else { + throw DashConnectUriError.invalidEphemeralPublicKey + } + + let contractId = Data(payload[pubKeyEnd ..< contractEnd]) + let labelLength = Int(payload[labelLengthIndex]) + guard labelLength <= maxLabelLength else { + throw DashConnectUriError.keyLabelTooLong + } + + let requiredLabelEndOffset = payload.distance(from: payload.startIndex, to: labelStart) + let expectedCount = requiredLabelEndOffset + labelLength + guard payload.count >= expectedCount else { + throw DashConnectUriError.keyLabelLengthOverrunsPayload + } + + let labelEnd = payload.index(labelStart, offsetBy: labelLength) + let label = String(decoding: payload[labelStart ..< labelEnd], as: UTF8.self) + + return DashKeyRequest( + appEphemeralPubKey: appEphemeralPubKey, + contractId: contractId, + label: label, + network: envelope.network + ) + } + + static func parseStRequest(_ uri: String) throws -> DashStRequest { + let envelope = try parseEnvelope(uri, scheme: stScheme, invalidScheme: .invalidStScheme) + guard !envelope.payload.isEmpty else { + throw DashConnectUriError.emptyTransitionPayload + } + + return DashStRequest(transitionBytes: envelope.payload, network: envelope.network) + } + + private static func parseEnvelope(_ uri: String, + scheme: String, + invalidScheme: DashConnectUriError) throws + -> (payload: Data, network: DashConnectNetwork) { + guard uri.hasPrefix(scheme) else { + throw invalidScheme + } + + let remainder = String(uri.dropFirst(scheme.count)) + guard !remainder.hasPrefix("//") else { + throw DashConnectUriError.unexpectedAuthority + } + + guard let queryIndex = remainder.firstIndex(of: "?") else { + throw DashConnectUriError.missingQuery + } + + let body = String(remainder[.. [String: String] { + var result: [String: String] = [:] + + for item in query.split(separator: "&", omittingEmptySubsequences: false) { + let parts = item.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let key = String(parts[0]) + let value = parts.count == 2 ? String(parts[1]) : "" + if !key.isEmpty, result[key] == nil { + result[key] = value + } + } + + return result + } + + private static func base58Decode(_ string: String) -> Data? { + let input = Array(string.utf8) + var leadingZeros = 0 + while leadingZeros < input.count, input[leadingZeros] == base58Alphabet[0] { + leadingZeros += 1 + } + + let size = input.count * 733 / 1000 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< input.count { + let byte = input[i] + guard byte < 128 else { return nil } + + let digit = base58Reverse[Int(byte)] + guard digit >= 0 else { return nil } + + var carry = Int(digit) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 58 * Int(buffer[j]) + buffer[j] = UInt8(carry & 0xff) + carry >>= 8 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8](repeating: 0, count: leadingZeros) + if start < buffer.count { + result.append(contentsOf: buffer[start...]) + } + return Data(result) + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift b/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift new file mode 100644 index 000000000..8f1d82b62 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/KeyExchangeCrypto.swift @@ -0,0 +1,221 @@ +import CryptoKit +import Foundation +import Security +import SwiftDashSDK + +enum KeyExchangeCrypto { + private enum CryptoError: LocalizedError { + case invalidSharedXLength + case invalidLoginKeyLength + case invalidIdentityIdLength + case invalidNonceLength + case invalidPayloadLength + case randomNonceGenerationFailed(OSStatus) + case invalidEncryptedPayload + case hash160Failed + + var errorDescription: String? { + switch self { + case .invalidSharedXLength: + "The ECDH shared X coordinate must be exactly 32 bytes." + case .invalidLoginKeyLength: + "DashConnect login keys must be exactly 32 bytes." + case .invalidIdentityIdLength: + "DashConnect identity IDs must be exactly 32 bytes." + case .invalidNonceLength: + "DashConnect AES-GCM nonces must be exactly 12 bytes." + case .invalidPayloadLength: + "DashConnect encrypted login-key payloads must be exactly 60 bytes." + case .randomNonceGenerationFailed(let status): + "Failed to generate a random AES-GCM nonce (\(status))." + case .invalidEncryptedPayload: + "Failed to assemble the AES-GCM encrypted login-key payload." + case .hash160Failed: + "Failed to compute hash160 via platform_wallet_hash160." + } + } + } + + private static let hash160Length = 20 + private static let aesKeyLength = 32 + private static let loginKeyLength = 32 + private static let nonceLength = 12 + private static let encryptedPayloadLength = 60 + private static let keyExchangeSalt = Data("dash:key-exchange:v1".utf8) + private static let authInfo = Data("auth".utf8) + private static let encryptionInfo = Data("encryption".utf8) + + /// RIPEMD160(SHA256(data)) using the shared Rust FFI helper. + /// + /// This deliberately duplicates the direct `platform_wallet_hash160` call pattern from + /// `KeychainManager.computePublicKeyHashHex` so we reuse the same primitive without + /// adding a Swift-side RIPEMD-160 implementation. Empty input or FFI failure returns + /// an empty Data sentinel, mirroring the existing KeychainManager helper. + static func hash160(_ data: Data) -> Data { + guard !data.isEmpty else { + return Data() + } + + var out = [UInt8](repeating: 0, count: hash160Length) + let rc: Int32 = data.withUnsafeBytes { raw -> Int32 in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { + return -1 + } + + return out.withUnsafeMutableBufferPointer { buffer -> Int32 in + guard let outBase = buffer.baseAddress else { + return -1 + } + + return platform_wallet_hash160(base, UInt(data.count), outBase) + } + } + + guard rc == 0 else { + zero(&out) + return Data() + } + + let result = Data(out) + zero(&out) + return result + } + + /// The returned Data is owned by the caller to clear. + static func deriveAesKey(sharedX: Data) throws -> Data { + try requireLength(sharedX, expected: aesKeyLength, error: CryptoError.invalidSharedXLength) + return try deriveHKDFKey( + inputKeyMaterial: sharedX, + salt: keyExchangeSalt, + info: Data(), + outputByteCount: aesKeyLength + ) + } + + /// The returned Data is owned by the caller to clear. + static func encryptLoginKey(_ loginKey: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data) throws -> Data { + var nonceBytes = [UInt8](repeating: 0, count: nonceLength) + let status = SecRandomCopyBytes(kSecRandomDefault, nonceBytes.count, &nonceBytes) + guard status == errSecSuccess else { + zero(&nonceBytes) + throw CryptoError.randomNonceGenerationFailed(status) + } + + let nonce = Data(nonceBytes) + zero(&nonceBytes) + return try encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: nonce + ) + } + + /// Fixed-nonce variant. Tests only — never call from the login flow. + /// + /// The returned Data is owned by the caller to clear. + static func encryptLoginKey(_ loginKey: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data, + nonce: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(nonce, expected: nonceLength, error: CryptoError.invalidNonceLength) + + let sharedX = try Secp256k1.ecdhSharedX(privateKey: walletEphemeralPriv, publicKey: appEphemeralPub) + let aesKey = try deriveAesKey(sharedX: sharedX) + let symmetricKey = SymmetricKey(data: aesKey) + let sealedBox = try AES.GCM.seal( + loginKey, + using: symmetricKey, + nonce: try AES.GCM.Nonce(data: nonce) + ) + + guard let combined = sealedBox.combined else { + throw CryptoError.invalidEncryptedPayload + } + assert(combined.count == encryptedPayloadLength) + guard combined.count == encryptedPayloadLength else { + throw CryptoError.invalidPayloadLength + } + + return combined + } + + /// Tests only — this exists to verify the key-exchange round trip and is not used by + /// the login flow. The returned Data is owned by the caller to clear. + static func decryptLoginKey(_ payload: Data, + walletEphemeralPriv: Data, + appEphemeralPub: Data) throws -> Data { + try requireLength(payload, expected: encryptedPayloadLength, error: CryptoError.invalidPayloadLength) + + let sharedX = try Secp256k1.ecdhSharedX(privateKey: walletEphemeralPriv, publicKey: appEphemeralPub) + let aesKey = try deriveAesKey(sharedX: sharedX) + let symmetricKey = SymmetricKey(data: aesKey) + let sealedBox = try AES.GCM.SealedBox(combined: payload) + let loginKey = try AES.GCM.open(sealedBox, using: symmetricKey) + + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + return loginKey + } + + /// The returned Data is owned by the caller to clear. + static func deriveAuthPrivateKey(loginKey: Data, identityId: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(identityId, expected: loginKeyLength, error: CryptoError.invalidIdentityIdLength) + + return try deriveHKDFKey( + inputKeyMaterial: loginKey, + salt: identityId, + info: authInfo, + outputByteCount: loginKeyLength + ) + } + + /// The returned Data is owned by the caller to clear. + static func deriveEncryptionPrivateKey(loginKey: Data, identityId: Data) throws -> Data { + try requireLength(loginKey, expected: loginKeyLength, error: CryptoError.invalidLoginKeyLength) + try requireLength(identityId, expected: loginKeyLength, error: CryptoError.invalidIdentityIdLength) + + return try deriveHKDFKey( + inputKeyMaterial: loginKey, + salt: identityId, + info: encryptionInfo, + outputByteCount: loginKeyLength + ) + } + + private static func deriveHKDFKey(inputKeyMaterial: Data, + salt: Data, + info: Data, + outputByteCount: Int) throws -> Data { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: inputKeyMaterial), + salt: salt, + info: info, + outputByteCount: outputByteCount + ) + + return key.withUnsafeBytes { bytes in + Data(bytes) + } + } + + private static func requireLength(_ data: Data, + expected: Int, + error: Error) throws { + guard data.count == expected else { + throw error + } + } + + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift b/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift new file mode 100644 index 000000000..8c5eca8c8 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/LoginKeyDerivation.swift @@ -0,0 +1,68 @@ +import CryptoKit +import Foundation + +enum LoginKeyDerivation { + static let defaultKeyIndex = 0 + + private enum DerivationError: LocalizedError { + case invalidChainKeyLength + case invalidIdentityIdLength + case invalidAppContractIdLength + + var errorDescription: String? { + switch self { + case .invalidChainKeyLength: + "The identity auth-chain private key must be exactly 32 bytes." + case .invalidIdentityIdLength: + "DashConnect identity IDs must be exactly 32 bytes." + case .invalidAppContractIdLength: + "DashConnect app contract IDs must be exactly 32 bytes." + } + } + } + + private static let loginKeyInfoPrefix = Data("dash:login-key:v1".utf8) + private static let keyLength = 32 + + /// The returned Data is owned by the caller to clear. + static func deriveLoginKey(chainKeyPrivateBytes: Data, + identityId: Data, + appContractId: Data) throws -> Data { + guard chainKeyPrivateBytes.count == keyLength else { + throw DerivationError.invalidChainKeyLength + } + guard identityId.count == keyLength else { + throw DerivationError.invalidIdentityIdLength + } + guard appContractId.count == keyLength else { + throw DerivationError.invalidAppContractIdLength + } + + var info = loginKeyInfoPrefix + info.append(appContractId) + // The HKDF info is protocol metadata (`dash:login-key:v1` + contract ID), not a + // secret. The returned login key remains caller-owned to clear. + return try deriveHKDFKey( + inputKeyMaterial: chainKeyPrivateBytes, + salt: identityId, + info: info, + outputByteCount: keyLength + ) + } + + private static func deriveHKDFKey(inputKeyMaterial: Data, + salt: Data, + info: Data, + outputByteCount: Int) throws -> Data { + let key = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: inputKeyMaterial), + salt: salt, + info: info, + outputByteCount: outputByteCount + ) + + return key.withUnsafeBytes { bytes in + Data(bytes) + } + } +} diff --git a/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift b/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift new file mode 100644 index 000000000..e5fb69bb9 --- /dev/null +++ b/DashWallet/Sources/Models/DashConnect/Protocol/Secp256k1.swift @@ -0,0 +1,124 @@ +import Foundation +import SwiftDashSDK + +/// The secp256k1 operations DashConnect's key exchange needs. Deliberately minimal — +/// this is not a general-purpose curve wrapper. +enum Secp256k1 { + private enum BackendError: Int, LocalizedError { + case invalidPrivateKeyLength = 1 + case invalidPrivateKey + case invalidPublicKeyLength + case invalidPublicKey + case pointAtInfinity + case backendUnavailable + + var errorDescription: String? { + switch self { + case .invalidPrivateKeyLength: + "secp256k1 private keys must be exactly 32 bytes." + case .invalidPrivateKey: + "The provided private key is not a valid secp256k1 scalar." + case .invalidPublicKeyLength: + "Compressed secp256k1 public keys must be exactly 33 bytes." + case .invalidPublicKey: + "The provided compressed point is not a valid secp256k1 public key." + case .pointAtInfinity: + "ECDH produced the point at infinity." + case .backendUnavailable: + "Failed to initialize the secp256k1 backend." + } + } + } + + /// True when `pubKey` is a well-formed 33-byte compressed point that lies on the + /// curve. The QR is untrusted input, so this gates every ECDH call. + static func isValidCompressedPoint(_ pubKey: Data) -> Bool { + do { + let publicKeyBytes = try validatedCompressedPublicKeyBytes(pubKey) + return Secp256k1Primitives.isValidCompressedPoint(Data(publicKeyBytes)) + } catch { + return false + } + } + + /// The 33-byte compressed public key for a 32-byte private key. + static func compressedPublicKey(privateKey: Data) throws -> Data { + var privateKeyBytes = try validatedPrivateKeyBytes(privateKey) + defer { zero(&privateKeyBytes) } + do { + return try Secp256k1Primitives.compressedPublicKey(privateKey: Data(privateKeyBytes)) + } catch let error as PlatformWalletError { + switch error { + case .invalidParameter: + throw BackendError.invalidPrivateKey + default: + throw BackendError.backendUnavailable + } + } catch { + throw BackendError.backendUnavailable + } + } + + /// The 32-byte big-endian X coordinate of `privateKey * publicKey`, left-padded. + /// + /// This is the ECDH shared secret the protocol feeds to HKDF. It is the raw affine X + /// of the product point — NOT hashed, and NOT the default hashed ECDH output that + /// applies SHA256 to the compressed point. Using the hashed variant would silently + /// produce a shared secret the DApp cannot reproduce. + static func ecdhSharedX(privateKey: Data, publicKey: Data) throws -> Data { + var privateKeyBytes = try validatedPrivateKeyBytes(privateKey) + defer { zero(&privateKeyBytes) } + + let publicKeyBytes = try validatedCompressedPublicKeyBytes(publicKey) + guard Secp256k1Primitives.isValidCompressedPoint(Data(publicKeyBytes)) else { + throw BackendError.invalidPublicKey + } + + do { + return try Secp256k1Primitives.ecdhSharedX( + privateKey: Data(privateKeyBytes), + publicKey: Data(publicKeyBytes) + ) + } catch let error as PlatformWalletError { + switch error { + case .invalidParameter: + throw BackendError.invalidPrivateKey + case .walletOperation: + throw BackendError.pointAtInfinity + default: + throw BackendError.backendUnavailable + } + } catch { + throw BackendError.backendUnavailable + } + } + + private static func validatedPrivateKeyBytes(_ privateKey: Data) throws -> [UInt8] { + guard privateKey.count == 32 else { + throw BackendError.invalidPrivateKeyLength + } + + return [UInt8](privateKey) + } + + private static func validatedCompressedPublicKeyBytes(_ publicKey: Data) throws -> [UInt8] { + guard publicKey.count == 33 else { + throw BackendError.invalidPublicKeyLength + } + + let bytes = [UInt8](publicKey) + guard bytes[0] == 0x02 || bytes[0] == 0x03 else { + throw BackendError.invalidPublicKey + } + + return bytes + } + private static func zero(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + return + } + baseAddress.initializeMemory(as: UInt8.self, repeating: 0, count: buffer.count) + } + } +} diff --git a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift index bdcc27205..f3f5f1406 100644 --- a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift +++ b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift @@ -17,11 +17,16 @@ // limitations under the License. // +import DashUIKit import SwiftUI struct ApproveConnectionSheet: View { let request: ConnectionRequest var isLoading: Bool = false + /// Why the last approve attempt failed. Shown here rather than as a screen alert: + /// an alert on the presenting screen cannot appear over this sheet, so the user + /// would otherwise see nothing at all. + var errorText: String? var onApprove: () -> Void var onDeny: () -> Void @@ -36,10 +41,7 @@ struct ApproveConnectionSheet: View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text( - String( - format: NSLocalizedString("Approve connection to %@?", comment: "DashConnect"), - resolvedAppLabel - ) + String(format: titleFormat, resolvedAppLabel) ) .font(.title2) .foregroundColor(.primaryText) @@ -49,56 +51,51 @@ struct ApproveConnectionSheet: View { .foregroundColor(.secondaryText) } - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("This app will be able to:", comment: "DashConnect")) - .font(.subhead) - .foregroundColor(.primaryText) - - PermissionRow( - icon: .custom("dashconnect.check.circle", maxHeight: 18), - text: NSLocalizedString("See your username", comment: "DashConnect") - ) - PermissionRow( - icon: .custom("dashconnect.check.circle", maxHeight: 18), - text: NSLocalizedString("Verify your identity", comment: "DashConnect") - ) + if let existingConnection = request.existingConnection { + reloginNotice(existingConnection) } - VStack(spacing: 4) { - DetailRow( - label: NSLocalizedString("Username", comment: "DashConnect"), - value: request.walletUsername - ) - DetailRow( - label: NSLocalizedString("Identity", comment: "DashConnect"), - value: truncateMiddle(request.walletIdentityId) - ) + if isRelogin { + reloginPermissionsSummary + } else { + fullPermissions } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.gray300, lineWidth: 1.5) - ) - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("This app will NOT have access to:", comment: "DashConnect")) - .font(.subhead) - .foregroundColor(.primaryText) - - PermissionRow( - icon: .custom("dashconnect.xmark.circle", maxHeight: 18), - text: NSLocalizedString("Your private keys", comment: "DashConnect") - ) - PermissionRow( - icon: .custom("dashconnect.xmark.circle", maxHeight: 18), - text: NSLocalizedString("Withdraw funds", comment: "DashConnect") + if request.walletUsername != nil || request.walletIdentityId != nil { + VStack(spacing: 4) { + if let walletUsername = request.walletUsername { + DetailRow( + label: NSLocalizedString("Username", comment: "DashConnect"), + value: walletUsername + ) + } + if let walletIdentityId = request.walletIdentityId { + DetailRow( + label: NSLocalizedString("Identity", comment: "DashConnect"), + value: truncateMiddle(walletIdentityId) + ) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) ) } Spacer(minLength: 0) VStack(spacing: 8) { + if let errorText { + Text(errorText) + .font(.footnote) + .foregroundColor(Color.dash.errorText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 4) + } + DashButton( text: NSLocalizedString("Approve", comment: "DashConnect"), style: .filledBlue, @@ -132,6 +129,18 @@ struct ApproveConnectionSheet: View { return trimmed.isEmpty ? NSLocalizedString("Unknown app", comment: "DashConnect") : trimmed } + private var isRelogin: Bool { + request.existingConnection != nil + } + + private var titleFormat: String { + if isRelogin { + return NSLocalizedString("Sign in to %@ again?", comment: "DashConnect re-login") + } else { + return NSLocalizedString("Approve connection to %@?", comment: "DashConnect") + } + } + private var resolvedSubtitle: String { let candidates = [ request.appUrl.trimmingCharacters(in: .whitespacesAndNewlines), @@ -141,12 +150,87 @@ struct ApproveConnectionSheet: View { return candidates.first(where: { !$0.isEmpty }) ?? NSLocalizedString("Unknown app", comment: "DashConnect") } + private var fullPermissions: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will be able to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("See your username", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.check.circle", maxHeight: 18), + text: NSLocalizedString("Verify your identity", comment: "DashConnect") + ) + } + + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("This app will NOT have access to:", comment: "DashConnect")) + .font(.subhead) + .foregroundColor(.primaryText) + + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Your private keys", comment: "DashConnect") + ) + PermissionRow( + icon: .custom("dashconnect.xmark.circle", maxHeight: 18), + text: NSLocalizedString("Withdraw funds", comment: "DashConnect") + ) + } + } + } + + private var reloginPermissionsSummary: some View { + HStack(spacing: 8) { + Icon(name: .custom("dashconnect.check.circle", maxHeight: 18)) + .frame(width: 18, height: 18) + + Text(NSLocalizedString("Same permissions as before", comment: "DashConnect re-login")) + .font(.subhead) + .foregroundColor(.primaryText) + } + } + + private func reloginNotice(_ existingConnection: DAppConnection) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(NSLocalizedString("This connection already exists.", comment: "DashConnect re-login")) + .font(.subhead) + .foregroundColor(.primaryText) + + Text( + String( + format: NSLocalizedString("Connected since %@", comment: "DashConnect re-login"), + Self.connectedSinceFormatter.string(from: existingConnection.updatedAt) + ) + ) + .font(.footnote) + .foregroundColor(.secondaryText) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) + ) + } + /// Truncates a long identifier in the middle, keeping `prefix` leading and /// `suffix` trailing characters (e.g. "5DbLwAx…7zUo8"). fileprivate func truncateMiddle(_ value: String, prefix: Int = 7, suffix: Int = 5) -> String { guard value.count > prefix + suffix + 1 else { return value } return "\(value.prefix(prefix))…\(value.suffix(suffix))" } + + private static let connectedSinceFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() } private struct PermissionRow: View { @@ -215,3 +299,18 @@ private let dashConnectTruncateMiddleCheck: Bool = { onDeny: {} ) } + +#Preview("Approve Re-Login") { + ApproveConnectionSheet( + request: ConnectionRequest( + loginRequest: MockDashConnectDataSource.sampleLoginRequest, + appLabel: "Yappr", + appUrl: "yap.pr", + walletUsername: "dashuser", + walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8", + existingConnection: MockDashConnectDataSource.sample(.active) + ), + onApprove: {}, + onDeny: {} + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift index 00bc5c6f1..9c6cf637c 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift @@ -21,7 +21,7 @@ import DashUIKit import SwiftUI /// One connected app: its name and url, the current status, and — once the -/// connection is live — the switch that disconnects it. +/// connection is live — the switch that lets this wallet forget it. /// /// An approved connection is not usable yet, so the row carries the prompt to /// finish the login underneath itself rather than leaving the list to know @@ -30,7 +30,7 @@ struct ConnectionRow: View { let connection: DAppConnection let onPrimaryAction: () -> Void let onMockScan: () -> Void - let onDisconnect: () -> Void + let onRemove: () -> Void private enum Layout { /// `SwitchView` is a fixed 64×28 from the design system and sets that @@ -52,7 +52,7 @@ struct ConnectionRow: View { .frame(maxWidth: .infinity, alignment: .topTrailing) if connection.status == .active { - disconnectSwitch + activeSwitch } } .padding(.horizontal, 14) @@ -81,14 +81,15 @@ struct ConnectionRow: View { } /// Always reads as on: the row only shows it for an active connection, and - /// switching it off is what disconnects. The binding therefore ignores the - /// on direction, which cannot be reached from here. - private var disconnectSwitch: some View { + /// only the off direction is reachable from here. Turning it off removes + /// this wallet's record of the connection; it does not sign the user out + /// of the app, because the wallet has no channel to end that session. + private var activeSwitch: some View { SwitchView(isOn: Binding( get: { true }, set: { isOn in if !isOn { - onDisconnect() + onRemove() } } )) @@ -110,7 +111,7 @@ struct ConnectionRow: View { ), onPrimaryAction: {}, onMockScan: {}, - onDisconnect: {} + onRemove: {} ) } } diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift index 78dd1776e..783353c85 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift @@ -51,8 +51,8 @@ struct ConnectionStatusBadge: View { } } - /// Green only once the connection is live; every other state is still in - /// progress or ended, and reads as neutral blue. + /// Green only once the connection is live; the only other state is + /// `approved`, which reads as neutral blue while it waits for the next scan. private var dotColor: Color { status == .active ? Color.dash.green : Color.dash.blue } @@ -63,8 +63,6 @@ struct ConnectionStatusBadge: View { return NSLocalizedString("Approved", comment: "DashConnect") case .active: return NSLocalizedString("Active", comment: "DashConnect") - case .disconnected: - return NSLocalizedString("Disconnected", comment: "DashConnect") } } } diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift index 2fbfea899..377850213 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift @@ -26,7 +26,7 @@ struct ConnectionsList: View { let connections: [DAppConnection] let onScanQR: () -> Void let onMockScan: () -> Void - let onDisconnect: (DAppConnection) -> Void + let onRemove: (DAppConnection) -> Void var body: some View { ScrollView(showsIndicators: false) { @@ -36,7 +36,7 @@ struct ConnectionsList: View { connection: connection, onPrimaryAction: onScanQR, onMockScan: onMockScan, - onDisconnect: { onDisconnect(connection) } + onRemove: { onRemove(connection) } ) } } @@ -58,7 +58,7 @@ struct ConnectionsList: View { }, onScanQR: {}, onMockScan: {}, - onDisconnect: { _ in } + onRemove: { _ in } ) .padding(20) .background(Color.primaryBackground) diff --git a/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift index 46a3a0d75..b430344e3 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ScanToCompleteBanner.swift @@ -21,7 +21,8 @@ import DashUIKit import SwiftUI /// Prompt attached to an approved connection: approval alone does not log the -/// user in, the app still has to scan the login QR to finish. +/// user in, and the same button accepts either the follow-up `dash-st:` QR or +/// a fresh `dash-key:` QR for the same app. struct ScanToCompleteBanner: View { let onScanQR: () -> Void let onMockScan: () -> Void diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift index 2fa1a15fd..6c8272cef 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -19,6 +19,7 @@ import SwiftUI import UIKit +import DashUIKit /// Lists the apps connected to this wallet. The states it can be in live in /// `Components/`; this type owns navigation, the QR scanner and the approval @@ -34,27 +35,48 @@ struct ConnectionsScreen: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - NavBarBack { - vc.popViewController(animated: true) + ZStack { + VStack(alignment: .leading, spacing: 0) { + DashUIKit.NavigationBar( + leading: { + NavigationBarElement.back.button { vc.popViewController(animated: true) } + } + ) + + TopIntro(title: NSLocalizedString("Connections", comment: "DashConnect")) + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 20) + + content + .alert(item: pendingRemovalBinding) { connection in + removalAlert(for: connection) + } } - TopIntro(title: NSLocalizedString("Connections", comment: "DashConnect")) - .padding(.leading, 20) - .padding(.trailing, 60) - .padding(.top, 10) - .padding(.bottom, 20) - - content + if viewModel.isProcessingKeyRegistration { + Color.black.opacity(0.08) + .ignoresSafeArea() + + // The app declares its own `ProgressView: UIView`, which shadows + // SwiftUI's in any file importing both. + SwiftUI.ProgressView(NSLocalizedString("Completing DashConnect request…", comment: "DashConnect")) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.primaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } } .background(Color.primaryBackground) .navigationBarHidden(true) - .alert(NSLocalizedString("Error", comment: ""), isPresented: errorIsPresented) { - Button(NSLocalizedString("OK", comment: "")) { - viewModel.errorMessage = nil - } - } message: { - Text(viewModel.errorMessage ?? "") + .alert(item: messageBinding) { message in + Alert( + title: Text(message.title), + message: Text(message.text), + dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) { + viewModel.message = nil + } + ) } .sheet(isPresented: isApproveSheetPresented) { if let request = viewModel.pendingRequest { @@ -75,8 +97,9 @@ struct ConnectionsScreen: View { connections: viewModel.connections, onScanQR: showScanner, onMockScan: mockScan, - onDisconnect: viewModel.disconnect + onRemove: viewModel.requestRemoval ) + .padding(.horizontal, 20) } } @@ -84,6 +107,7 @@ struct ConnectionsScreen: View { ApproveConnectionSheet( request: request, isLoading: viewModel.isApproving, + errorText: viewModel.approveError, onApprove: { viewModel.approvePendingRequest() }, onDeny: { viewModel.denyPendingRequest() } ) @@ -103,14 +127,17 @@ struct ConnectionsScreen: View { ) } - private var errorIsPresented: Binding { + private var messageBinding: Binding { Binding( - get: { viewModel.errorMessage != nil }, - set: { isPresented in - if !isPresented { - viewModel.errorMessage = nil - } - } + get: { viewModel.message }, + set: { viewModel.message = $0 } + ) + } + + private var pendingRemovalBinding: Binding { + Binding( + get: { viewModel.pendingRemoval }, + set: { viewModel.pendingRemoval = $0 } ) } @@ -119,8 +146,6 @@ struct ConnectionsScreen: View { private func showScanner() { let scanner = GenericQRScannerController() - // The mock UI flow accepts any scanned payload for now; MO-945 will - // replace this with real login-QR parsing. scanner.onQRCodeScanned = { value in vc.dismiss(animated: true) { viewModel.onQRScanned(value) @@ -134,7 +159,33 @@ struct ConnectionsScreen: View { } private func mockScan() { - viewModel.onQRScanned("dash-key:mock") + viewModel.onQRScanned(MockDashConnectDataSource.sampleLoginQRCode) + } + + private func removalAlert(for connection: DAppConnection) -> Alert { + Alert( + title: Text( + String( + format: NSLocalizedString("Disconnect %@?", comment: "DashConnect"), + connection.name + ) + ), + message: Text( + String( + format: NSLocalizedString( + "This removes the connection from this wallet. %@ may stay signed in until you sign out there.", + comment: "DashConnect" + ), + connection.name + ) + ), + primaryButton: .destructive(Text(NSLocalizedString("Disconnect", comment: "DashConnect"))) { + viewModel.removeConnection(connection) + }, + secondaryButton: .cancel { + viewModel.cancelPendingRemoval() + } + ) } } @@ -185,13 +236,3 @@ struct ConnectionsScreen: View { ) ) } - -#Preview("Disconnected") { - ConnectionsScreen( - vc: UINavigationController(), - viewModel: ConnectionsViewModel( - dataSource: MockDashConnectDataSource(initial: [MockDashConnectDataSource.sample(.disconnected)]), - featureUnavailable: false - ) - ) -} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift index c299a4541..cc1ab1143 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -20,27 +20,58 @@ import Combine import Foundation +struct ConnectionsScreenMessage: Identifiable, Equatable { + enum Kind: String { + case error + case success + } + + let kind: Kind + let text: String + + var id: String { + "\(kind.rawValue):\(text)" + } + + var title: String { + switch kind { + case .error: + return NSLocalizedString("Error", comment: "") + case .success: + return NSLocalizedString("Success", comment: "DashConnect") + } + } +} + @MainActor final class ConnectionsViewModel: ObservableObject { @Published private(set) var connections: [DAppConnection] = [] @Published private(set) var featureUnavailable: Bool @Published var pendingRequest: ConnectionRequest? @Published var isApproving = false - @Published var errorMessage: String? + @Published var isProcessingKeyRegistration = false + @Published var message: ConnectionsScreenMessage? + @Published var pendingRemoval: DAppConnection? + /// Failure of the last approve attempt, rendered **inside** the approve sheet. + /// A screen-level `.alert` cannot appear over a presented sheet, so routing this + /// through `message` would leave the user with no feedback at all. + @Published var approveError: String? private let dataSource: any DashConnectDataSource + private var pendingLoginRequest: DashKeyRequest? private var cancellables = Set() init( - dataSource: any DashConnectDataSource = MockDashConnectDataSource(), + dataSource: (any DashConnectDataSource)? = nil, featureUnavailable: Bool? = nil ) { - self.dataSource = dataSource - self.featureUnavailable = featureUnavailable ?? ( + let computedFeatureUnavailable = featureUnavailable ?? ( DWEnvironment.sharedInstance().currentChain.chainType.tag != ChainType_TestNet ) + self.featureUnavailable = computedFeatureUnavailable + self.dataSource = dataSource ?? Self.defaultDataSource(featureUnavailable: computedFeatureUnavailable) - dataSource.connections + self.dataSource.connections .receive(on: DispatchQueue.main) .sink { [weak self] in self?.connections = $0.sorted(by: { $0.updatedAt > $1.updatedAt }) @@ -53,29 +84,52 @@ final class ConnectionsViewModel: ObservableObject { Task { do { - pendingRequest = try await dataSource.parseQR(content) + pendingRequest = nil + pendingLoginRequest = nil + + switch try await dataSource.parseQR(content) { + case let .login(request): + pendingLoginRequest = request + pendingRequest = await dataSource.makeConnectionRequest(from: request) + case let .keyRegistration(request): + isProcessingKeyRegistration = true + defer { isProcessingKeyRegistration = false } + + try await dataSource.completeKeyRegistration(request) + message = ConnectionsScreenMessage( + kind: .success, + text: NSLocalizedString("DashConnect key registration completed.", comment: "DashConnect") + ) + } } catch { - errorMessage = String( - format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), - error.localizedDescription + message = ConnectionsScreenMessage( + kind: .error, + text: String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) ) } } } func approvePendingRequest() { - guard let pendingRequest, !isApproving else { return } + guard pendingRequest != nil, let pendingLoginRequest, !isApproving else { return } isApproving = true + approveError = nil Task { defer { isApproving = false } do { - _ = try await dataSource.approve(pendingRequest) + _ = try await dataSource.approveLogin(pendingLoginRequest) self.pendingRequest = nil + self.pendingLoginRequest = nil + self.approveError = nil } catch { - errorMessage = String( + // Keep the sheet up so the user can retry without rescanning the QR. + self.approveError = String( format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), error.localizedDescription ) @@ -86,17 +140,35 @@ final class ConnectionsViewModel: ObservableObject { func denyPendingRequest() { guard !isApproving else { return } pendingRequest = nil + pendingLoginRequest = nil + approveError = nil } - func disconnect(_ connection: DAppConnection) { - Task { - await dataSource.disconnect(id: connection.id) - } + func requestRemoval(_ connection: DAppConnection) { + pendingRemoval = connection + } + + func cancelPendingRemoval() { + pendingRemoval = nil } func removeConnection(_ connection: DAppConnection) { + pendingRemoval = nil Task { await dataSource.remove(id: connection.id) } } + + private static func defaultDataSource(featureUnavailable: Bool) -> any DashConnectDataSource { + let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + if isPreview || featureUnavailable { + return MockDashConnectDataSource() + } + + assert( + DWEnvironment.sharedInstance().currentChain.chainType.tag == ChainType_TestNet, + "DashConnect real data source must only run on testnet." + ) + return PlatformDashConnectDataSource() + } } diff --git a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift index edaf391bd..d139b4252 100644 --- a/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/ToolsMenuViewModel.swift @@ -134,7 +134,7 @@ class ToolsMenuViewModel: ObservableObject { ), MenuItemModel( title: NSLocalizedString("Connections", comment: "DashConnect"), - icon: .custom("menu.connections", maxHeight: 30), + icon: .custom("menu-connections", maxHeight: 30), action: { [weak self] in self?.navigationDestination = .connections } diff --git a/DashWalletTests/AmountObjectTests.swift b/DashWalletTests/AmountObjectTests.swift index 3000b654b..3f24243a4 100644 --- a/DashWalletTests/AmountObjectTests.swift +++ b/DashWalletTests/AmountObjectTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // MARK: - MockRatesProvider diff --git a/DashWalletTests/DWAvatarUploadClientTests.swift b/DashWalletTests/DWAvatarUploadClientTests.swift index 87deada49..8f868093a 100644 --- a/DashWalletTests/DWAvatarUploadClientTests.swift +++ b/DashWalletTests/DWAvatarUploadClientTests.swift @@ -7,7 +7,7 @@ import Moya import UIKit import XCTest -@testable import dashwallet +@testable import dashpay final class DWAvatarUploadClientTests: XCTestCase { private final class Recorder: @unchecked Sendable { diff --git a/DashWalletTests/DWContestedNameStatusServiceTests.swift b/DashWalletTests/DWContestedNameStatusServiceTests.swift index 18203eb19..915a40559 100644 --- a/DashWalletTests/DWContestedNameStatusServiceTests.swift +++ b/DashWalletTests/DWContestedNameStatusServiceTests.swift @@ -8,7 +8,7 @@ import SwiftDashSDK import XCTest -@testable import dashwallet +@testable import dashpay @MainActor final class DWContestedNameStatusServiceTests: XCTestCase { diff --git a/DashWalletTests/DWRegistrationPhaseAdapterTests.swift b/DashWalletTests/DWRegistrationPhaseAdapterTests.swift index fec5b80c8..377241873 100644 --- a/DashWalletTests/DWRegistrationPhaseAdapterTests.swift +++ b/DashWalletTests/DWRegistrationPhaseAdapterTests.swift @@ -10,7 +10,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // NOTE: DashWalletTests links against the `dashwallet` target, but the // types under test (`DWIdentityRegistrationController`, diff --git a/DashWalletTests/DashAmountFormatterTests.swift b/DashWalletTests/DashAmountFormatterTests.swift index def8e969c..1d7ea3a17 100644 --- a/DashWalletTests/DashAmountFormatterTests.swift +++ b/DashWalletTests/DashAmountFormatterTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class DashAmountFormatterTests: XCTestCase { diff --git a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift new file mode 100644 index 000000000..e20063339 --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift @@ -0,0 +1,253 @@ +import XCTest +@testable import dashpay + +final class DashConnectDataSourceTests: XCTestCase { + private let appPrivateKey = Data(repeating: 0x01, count: 32) + private let contractId = Data(repeating: 0xcd, count: 32) + private let label = "Login to Yappr" + + func testParseQRRoutesValidDashKeyUriToLoginRequest() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(validKeyUri()) + + guard case let .login(request) = result else { + return XCTFail("Expected a login request") + } + + XCTAssertEqual(request.contractId, contractId) + XCTAssertEqual(request.label, label) + XCTAssertEqual(request.network, .testnet) + } + + func testParseQRRoutesValidDashStUriToKeyRegistration() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(validStUri()) + + guard case let .keyRegistration(request) = result else { + return XCTFail("Expected a key registration request") + } + + XCTAssertEqual(request.transitionBytes, Data([0x01, 0x02, 0x03, 0x04])) + XCTAssertEqual(request.network, .testnet) + } + + func testParseQRRejectsJunkPayloads() async { + let dataSource = MockDashConnectDataSource() + let invalidPayloads = [ + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty", + "hello world", + "", + ] + + for payload in invalidPayloads { + await XCTAssertThrowsErrorAsync({ + try await dataSource.parseQR(payload) + }) { error in + XCTAssertEqual(error as? DashConnectMockError, .notDashConnectQrCode) + } + } + } + + func testParseQRRejectsWrongNetwork() async { + let dataSource = MockDashConnectDataSource(supportedNetwork: .testnet) + + await XCTAssertThrowsErrorAsync({ + try await dataSource.parseQR(validKeyUri(network: .mainnet)) + }) { error in + XCTAssertEqual( + error as? DashConnectMockError, + .unsupportedNetwork(expected: .testnet, actual: .mainnet) + ) + } + } + + func testParseQRToleratesLeadingAndTrailingWhitespace() async throws { + let dataSource = MockDashConnectDataSource() + + let result = try await dataSource.parseQR(" \n\(validKeyUri())\n\t ") + + guard case let .login(request) = result else { + return XCTFail("Expected a login request") + } + + XCTAssertEqual(request.contractId, contractId) + } + + func testCompleteKeyRegistrationThrowsOnMock() async throws { + let dataSource = MockDashConnectDataSource() + + await XCTAssertThrowsErrorAsync({ + try await dataSource.completeKeyRegistration(.init( + transitionBytes: Data([0x01, 0x02, 0x03, 0x04]), + network: .testnet + )) + }) { error in + XCTAssertEqual(error as? DashConnectMockError, .keyRegistrationNotSupported) + } + } + + func testMakeConnectionRequestCarriesExistingConnectionForMatchingContract() async { + let existing = MockDashConnectDataSource.sample(.active) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testMakeConnectionRequestCarriesApprovedConnection() async { + let existing = MockDashConnectDataSource.sample(.approved) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testApprovingSameAppTwiceReplacesExistingRow() async throws { + let initialDate = Date(timeIntervalSince1970: 1_773_132_300) + let existing = DAppConnection( + id: MockDashConnectDataSource.sample(.approved).id, + name: "Yappr", + url: "yap.pr", + status: .approved, + updatedAt: initialDate + ) + let dataSource = MockDashConnectDataSource(initial: [existing]) + + _ = try await dataSource.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(dataSource.connectionsSnapshot.count, 1) + XCTAssertEqual(dataSource.connectionsSnapshot.first?.id, existing.id) + XCTAssertTrue((dataSource.connectionsSnapshot.first?.updatedAt ?? .distantPast) > initialDate) + } + + private func validKeyUri(network: DashConnectNetwork = .testnet) -> String { + let labelData = Data(label.utf8) + let payload = data([ + Data([0x01]), + try! validEphemeralPublicKey(), + contractId, + Data([UInt8(labelData.count)]), + labelData, + ]) + return "dash-key:\(base58Encode(payload))?n=\(network.rawValue)&v=1" + } + + private func validStUri(network: DashConnectNetwork = .testnet) -> String { + "dash-st:\(base58Encode(Data([0x01, 0x02, 0x03, 0x04])))?n=\(network.rawValue)&v=1" + } + + private func validEphemeralPublicKey() throws -> Data { + try Secp256k1.compressedPublicKey(privateKey: appPrivateKey) + } + + private func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) + } + + private func data(_ chunks: [Data]) -> Data { + var result = Data() + for chunk in chunks { + result.append(chunk) + } + return result + } + + private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> T, + _ handler: (Error) -> Void + ) async { + do { + _ = try await expression() + XCTFail("Expected error to be thrown") + } catch { + handler(error) + } + } +} + +// MARK: - Branding is keyed on the contract id, not the spoofable label + +extension DashConnectDataSourceTests { + /// Yappr's data contract on testnet, taken from a real login QR. + private static let yapprContractId = Data([ + 0xc8, 0xb1, 0x02, 0xb5, 0x6e, 0xa7, 0xbb, 0xac, + 0xf7, 0x72, 0xd8, 0x36, 0x23, 0xf8, 0xd1, 0x8c, + 0x53, 0xae, 0xf2, 0x07, 0x66, 0x59, 0x9a, 0xc6, + 0x82, 0xb6, 0xa0, 0x19, 0xf2, 0x9d, 0x35, 0x3e + ]) + + private func makeLoginRequest(contractId: Data, label: String) throws -> DashKeyRequest { + DashKeyRequest( + appEphemeralPubKey: try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)), + contractId: contractId, + label: label, + network: .testnet + ) + } + + /// A QR may claim any label it likes. Branding must follow the contract id, otherwise + /// an attacker's contract renders with Yappr's name and website. + func testBrandingIgnoresSpoofedLabelOnUnknownContract() throws { + let request = try makeLoginRequest( + contractId: Data(repeating: 0x11, count: 32), + label: "Login to Yappr" + ) + + let connectionRequest = ConnectionRequest(loginRequest: request) + + XCTAssertEqual(connectionRequest.appLabel, "Login to Yappr") + XCTAssertEqual( + connectionRequest.appUrl, + "", + "An unknown contract must not borrow a known app's website." + ) + } + + /// The real Yappr contract resolves to its branding whatever the label says. + func testBrandingResolvesKnownContractRegardlessOfLabel() throws { + let request = try makeLoginRequest( + contractId: Self.yapprContractId, + label: "anything at all" + ) + + let connectionRequest = ConnectionRequest(loginRequest: request) + + XCTAssertEqual(connectionRequest.appContractId, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(connectionRequest.appLabel, "Yappr") + XCTAssertEqual(connectionRequest.appUrl, "yap.pr") + } +} diff --git a/DashWalletTests/DashConnect/DashConnectStoreTests.swift b/DashWalletTests/DashConnect/DashConnectStoreTests.swift new file mode 100644 index 000000000..b912cd798 --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectStoreTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import dashpay + +final class DashConnectStoreTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "DashConnectStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testRoundTripPreservesConnections() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let connections = [ + sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)), + sampleConnection(id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", name: "DashGet", url: "dashget.app", status: .active, updatedAt: Date(timeIntervalSince1970: 20)), + sampleConnection(id: "AaC695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNgA9Z", name: "PayKit", url: "paykit.dev", status: .approved, updatedAt: Date(timeIntervalSince1970: 30)), + ] + + store.save(connections) + + XCTAssertEqual(store.load(), connections) + } + + func testUnknownStatusDropsOnlyThatRow() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [ + [ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + [ + "contractId": "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", + "label": "DashGet", + "url": "dashget.app", + "status": "mystery", + "updatedAt": 1_773_218_700_000 + ], + ] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual( + store.load(), + [sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 1_773_132_300))] + ) + } + + func testMalformedRowIsDropped() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [ + [ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + [ + "label": "Broken", + "url": "broken.app", + "status": "approved", + "updatedAt": 1_773_132_300_000 + ], + ] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual(store.load().count, 1) + XCTAssertEqual(store.load().first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + } + + func testLegacyDisconnectedStatusIsDropped() throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let payload: [[String: Any]] = [[ + "contractId": "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + "label": "Yappr", + "url": "yap.pr", + "status": "disconnected", + "updatedAt": 1_773_132_300_000 + ]] + + defaults.set(try JSONSerialization.data(withJSONObject: payload), forKey: store.storageKey) + + XCTAssertEqual(store.load(), []) + } + + func testEmptyStoreLoadsAsEmptyArray() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + XCTAssertEqual(store.load(), []) + } + + func testSavingEmptyArrayClearsStore() { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + store.save([sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10))]) + + store.save([]) + + XCTAssertNil(defaults.data(forKey: store.storageKey)) + XCTAssertEqual(store.load(), []) + } + + func testNetworksAreIsolated() { + let testnetStore = makeStore(network: .testnet, walletIdHex: "wallet-a") + let mainnetStore = makeStore(network: .mainnet, walletIdHex: "wallet-a") + let testnetConnection = sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)) + let mainnetConnection = sampleConnection(id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", status: .active, updatedAt: Date(timeIntervalSince1970: 20)) + + testnetStore.save([testnetConnection]) + mainnetStore.save([mainnetConnection]) + + XCTAssertEqual(testnetStore.load(), [testnetConnection]) + XCTAssertEqual(mainnetStore.load(), [mainnetConnection]) + XCTAssertNotEqual(testnetStore.storageKey, mainnetStore.storageKey) + XCTAssertTrue(testnetStore.storageKey.contains(".t.")) + XCTAssertTrue(mainnetStore.storageKey.contains(".m.")) + } + + func testWalletIdsAreIsolated() { + let walletAStore = makeStore(network: .testnet, walletIdHex: "wallet-a") + let walletBStore = makeStore(network: .testnet, walletIdHex: "wallet-b") + let walletAConnection = sampleConnection(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .approved, updatedAt: Date(timeIntervalSince1970: 10)) + + walletAStore.save([walletAConnection]) + + XCTAssertEqual(walletAStore.load(), [walletAConnection]) + XCTAssertEqual(walletBStore.load(), []) + XCTAssertNotEqual(walletAStore.storageKey, walletBStore.storageKey) + } + + func testApproveLoginPersistsAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource(store: store) + + _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + let second = MockDashConnectDataSource(store: store) + + XCTAssertEqual(second.connectionsSnapshot.count, 1) + XCTAssertEqual(second.connectionsSnapshot.first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(second.connectionsSnapshot.first?.status, .approved) + } + + func testRemovePersistsAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource(store: store) + _ = try await first.approveLogin(MockDashConnectDataSource.sampleLoginRequest) + + await first.remove(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + + let second = MockDashConnectDataSource(store: store) + XCTAssertEqual(second.connectionsSnapshot, []) + } + + private func makeStore(network: DashConnectNetwork, walletIdHex: String?) -> UserDefaultsDashConnectStore { + UserDefaultsDashConnectStore( + defaults: defaults, + network: network, + walletIdHexProvider: { walletIdHex } + ) + } + + private func sampleConnection( + id: String, + name: String = "Yappr", + url: String = "yap.pr", + status: ConnectionStatus, + updatedAt: Date + ) -> DAppConnection { + DAppConnection(id: id, name: name, url: url, status: status, updatedAt: updatedAt) + } +} diff --git a/DashWalletTests/DashConnect/DashConnectUriTests.swift b/DashWalletTests/DashConnect/DashConnectUriTests.swift new file mode 100644 index 000000000..95761083b --- /dev/null +++ b/DashWalletTests/DashConnect/DashConnectUriTests.swift @@ -0,0 +1,199 @@ +import XCTest +@testable import dashpay + +final class DashConnectUriTests: XCTestCase { + private let appPrivateKey = Data(repeating: 0x01, count: 32) + private let contractId = Data(repeating: 0xcd, count: 32) + private let label = "Login to Yappr" + + func testParsesValidDashKeyRequest() throws { + let request = try DashConnectUri.parseKeyRequest(validKeyUri()) + + XCTAssertEqual(request.network, .testnet) + XCTAssertEqual( + request.appEphemeralPubKey.hexEncodedString(), + "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + ) + XCTAssertEqual(request.contractId, contractId) + XCTAssertEqual(request.label, label) + } + + func testParsesValidDashStRequest() throws { + let request = try DashConnectUri.parseStRequest(validStUri()) + XCTAssertEqual(request.network, .testnet) + XCTAssertEqual(request.transitionBytes, Data([0x01, 0x02, 0x03, 0x04])) + } + + func testRecognizesUriKinds() { + XCTAssertTrue(DashConnectUri.isKeyUri(validKeyUri())) + XCTAssertFalse(DashConnectUri.isKeyUri(validStUri())) + XCTAssertTrue(DashConnectUri.isStUri(validStUri())) + XCTAssertFalse(DashConnectUri.isStUri(validKeyUri())) + } + + func testParsesAllSupportedNetworks() throws { + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .mainnet)).network, .mainnet) + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .testnet)).network, .testnet) + XCTAssertEqual(try DashConnectUri.parseKeyRequest(validKeyUri(network: .devnet)).network, .devnet) + } + + func testRejectsWrongScheme() { + assertKeyError("dash:123?n=t&v=1", .invalidKeyScheme) + assertStError("dash:123?n=t&v=1", .invalidStScheme) + } + + func testRejectsAuthorityComponent() { + assertKeyError("dash-key://abc?n=t&v=1", .unexpectedAuthority) + assertStError("dash-st://abc?n=t&v=1", .unexpectedAuthority) + } + + func testRejectsMissingQueryAndEmptyBody() { + assertKeyError("dash-key:abc", .missingQuery) + assertKeyError("dash-key:?n=t&v=1", .emptyBody) + assertStError("dash-st:abc", .missingQuery) + assertStError("dash-st:?n=t&v=1", .emptyBody) + } + + func testRejectsMissingNetworkAndMissingVersion() { + assertKeyError(validKeyUri(query: "v=1"), .missingNetwork) + assertKeyError(validKeyUri(query: "n=t"), .missingVersion) + assertStError(validStUri(query: "v=1"), .missingNetwork) + assertStError(validStUri(query: "n=t"), .missingVersion) + } + + func testRejectsUnsupportedVersionAndUnknownNetwork() { + assertKeyError(validKeyUri(query: "n=t&v=2"), .unsupportedVersion) + assertKeyError(validKeyUri(query: "n=x&v=1"), .unknownNetwork) + assertStError(validStUri(query: "n=t&v=2"), .unsupportedVersion) + assertStError(validStUri(query: "n=x&v=1"), .unknownNetwork) + } + + func testRejectsInvalidBase58() { + assertKeyError("dash-key:0OIl?n=t&v=1", .invalidBase58) + assertStError("dash-st:0OIl?n=t&v=1", .invalidBase58) + } + + func testRejectsShortKeyPayloadAndWrongPayloadVersion() { + assertKeyError(keyUri(payload: Data(repeating: 0x00, count: 66)), .keyPayloadTooShort) + + var payload = validKeyPayload() + payload[0] = 0x02 + assertKeyError(keyUri(payload: payload), .invalidKeyPayloadVersion) + } + + func testRejectsTooLongAndOverrunningLabelLengths() { + var tooLong = validKeyPayload() + tooLong[66] = 65 + assertKeyError(keyUri(payload: tooLong), .keyLabelTooLong) + + var overrunning = validKeyPayload() + overrunning[66] = UInt8(label.utf8.count + 1) + assertKeyError(keyUri(payload: overrunning), .keyLabelLengthOverrunsPayload) + } + + func testRejectsInvalidEphemeralPoint() { + var payload = validKeyPayload() + payload.replaceSubrange(1 ..< 34, with: data([Data([0x02]), Data(repeating: 0x00, count: 32)])) + assertKeyError(keyUri(payload: payload), .invalidEphemeralPublicKey) + } + + func testAllowsEmptyLabel() throws { + let ephemeralKey = try validEphemeralPublicKey() + let payload = Data([0x01]) + ephemeralKey + contractId + Data([0x00]) + let request = try DashConnectUri.parseKeyRequest(keyUri(payload: payload)) + XCTAssertEqual(request.label, "") + } + + func testRejectsEmptyTransitionBody() { + assertStError("dash-st:?n=t&v=1", .emptyBody) + } + + private func assertKeyError(_ uri: String, _ expected: DashConnectUriError) { + XCTAssertThrowsError(try DashConnectUri.parseKeyRequest(uri)) { error in + XCTAssertEqual(error as? DashConnectUriError, expected) + } + } + + private func assertStError(_ uri: String, _ expected: DashConnectUriError) { + XCTAssertThrowsError(try DashConnectUri.parseStRequest(uri)) { error in + XCTAssertEqual(error as? DashConnectUriError, expected) + } + } + + private func validKeyUri(network: DashConnectNetwork = .testnet, query: String? = nil) -> String { + keyUri(payload: validKeyPayload(), network: network, query: query) + } + + private func validStUri(network: DashConnectNetwork = .testnet, query: String? = nil) -> String { + stUri(payload: Data([0x01, 0x02, 0x03, 0x04]), network: network, query: query) + } + + private func keyUri(payload: Data, + network: DashConnectNetwork = .testnet, + query: String? = nil) -> String { + "dash-key:\(base58Encode(payload))?\(query ?? "n=\(network.rawValue)&v=1")" + } + + private func stUri(payload: Data, + network: DashConnectNetwork = .testnet, + query: String? = nil) -> String { + "dash-st:\(base58Encode(payload))?\(query ?? "n=\(network.rawValue)&v=1")" + } + + private func validKeyPayload() -> Data { + let labelData = Data(label.utf8) + return data([ + Data([0x01]), + try! validEphemeralPublicKey(), + contractId, + Data([UInt8(labelData.count)]), + labelData, + ]) + } + + private func validEphemeralPublicKey() throws -> Data { + try Secp256k1.compressedPublicKey(privateKey: appPrivateKey) + } + + private func base58Encode(_ data: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".utf8) + let bytes = [UInt8](data) + var leadingZeros = 0 + while leadingZeros < bytes.count, bytes[leadingZeros] == 0 { + leadingZeros += 1 + } + + let size = (bytes.count - leadingZeros) * 138 / 100 + 1 + var buffer = [UInt8](repeating: 0, count: size) + + for i in leadingZeros ..< bytes.count { + var carry = Int(bytes[i]) + for j in stride(from: size - 1, through: 0, by: -1) { + carry += 256 * Int(buffer[j]) + buffer[j] = UInt8(carry % 58) + carry /= 58 + } + } + + var start = 0 + while start < size, buffer[start] == 0 { + start += 1 + } + + var result = [UInt8]() + result.reserveCapacity(leadingZeros + size - start) + result.append(contentsOf: repeatElement(alphabet[0], count: leadingZeros)) + for i in start ..< size { + result.append(alphabet[Int(buffer[i])]) + } + return String(decoding: result, as: UTF8.self) + } + + private func data(_ chunks: [Data]) -> Data { + var result = Data() + for chunk in chunks { + result.append(chunk) + } + return result + } +} diff --git a/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift b/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift new file mode 100644 index 000000000..4fedffb53 --- /dev/null +++ b/DashWalletTests/DashConnect/KeyExchangeCryptoTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import dashpay + +final class KeyExchangeCryptoTests: XCTestCase { + private let sharedX = Data(hex: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")! + private let loginKey = Data(hex: "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f")! + private let identityId = Data(hex: "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")! + private let walletEphemeralPriv = Data(repeating: 0x01, count: 32) + private let appEphemeralPub = Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")! + private let fixedNonce = Data(hex: "101112131415161718191a1b")! + private let kotlinAppPrivateKey = Data(repeating: 0x01, count: 32) + private let kotlinWalletPrivateKey = Data(repeating: 0x02, count: 32) + private let kotlinIdentityId = Data(repeating: 0xab, count: 32) + private let kotlinContractId = Data(repeating: 0xcd, count: 32) + private let kotlinFixedNonce = Data((0 ..< 12).map(UInt8.init)) + private let kotlinLoginKey = Data((0 ..< 32).map { UInt8(($0 * 7 + 3) & 0xff) }) + + func testDeriveAesKeyMatchesVector() throws { + let derived = try KeyExchangeCrypto.deriveAesKey(sharedX: sharedX) + XCTAssertEqual(derived.hexEncodedString(), "0d14bc5a19d237e70d82bd357114307629c262c77fcde496d51dfeaa35d124c2") + } + + func testEncryptLoginKeyWithFixedNonceMatchesVector() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + + XCTAssertEqual(payload.hexEncodedString(), "101112131415161718191a1b914189444d0fee69f81c2da90b6e1f451e003d9ef6196f231601d0dbd93c523969bcb8d144051b2b9badadb6e3a80fc8") + } + + func testEncryptDecryptLoginKeyRoundTrips() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + ) + let decrypted = try KeyExchangeCrypto.decryptLoginKey( + payload, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + ) + + XCTAssertEqual(decrypted, loginKey) + } + + func testPayloadHasExpectedLengthAndNoncePrefix() throws { + let payload = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + + XCTAssertEqual(payload.count, 60) + XCTAssertEqual(payload.prefix(fixedNonce.count), fixedNonce) + } + + func testDerivedIdentityKeysMatchVectorsAndDifferByInfo() throws { + let auth = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let encryption = try KeyExchangeCrypto.deriveEncryptionPrivateKey(loginKey: loginKey, identityId: identityId) + + XCTAssertEqual(auth.hexEncodedString(), "8b37cb699757243ca057a2d691ab43f88479f7422729eee270327a327d0c2bdc") + XCTAssertEqual(encryption.hexEncodedString(), "3b658157640c41f675c6858794d4f184f55112c938715085470e056f2970cfa3") + XCTAssertNotEqual(auth, encryption) + } + + func testHash160MatchesKnownVector() { + let input = Data(repeating: 0x00, count: 33) + let hash = KeyExchangeCrypto.hash160(input) + + XCTAssertEqual(hash.hexEncodedString(), "29cfc6376255a78451eeb4b129ed8eacffa2feef") + } + + func testKotlinHash160VectorMatchesExpectedHex() { + let hash = KeyExchangeCrypto.hash160(Data([0x01, 0x02, 0x03])) + XCTAssertEqual(hash.hexEncodedString(), "9bc4860bb936abf262d7a51f74b4304833fee3b2") + } + + func testKotlinAuthPrivateKeyVectorMatchesExpectedHex() throws { + let auth = try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: kotlinLoginKey, + identityId: kotlinIdentityId + ) + + XCTAssertEqual(auth.hexEncodedString(), "e06ee7ae45f257741dab7379793c854829b67171af111238c664d9fd90603706") + } + + func testKotlinEncryptionPrivateKeyVectorMatchesExpectedHex() throws { + let encryption = try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: kotlinLoginKey, + identityId: kotlinIdentityId + ) + + XCTAssertEqual(encryption.hexEncodedString(), "6879c11819d1a60026adae34c296e83d13d06559ad63da41d03c44b203a90f80") + } + + func testKotlinFixedNoncePayloadHasExpectedShapeAndRoundTrips() throws { + let appEphemeralPub = try Secp256k1.compressedPublicKey(privateKey: kotlinAppPrivateKey) + let payload = try KeyExchangeCrypto.encryptLoginKey( + kotlinLoginKey, + walletEphemeralPriv: kotlinWalletPrivateKey, + appEphemeralPub: appEphemeralPub, + nonce: kotlinFixedNonce + ) + let decrypted = try KeyExchangeCrypto.decryptLoginKey( + payload, + walletEphemeralPriv: kotlinWalletPrivateKey, + appEphemeralPub: appEphemeralPub + ) + + XCTAssertEqual(payload.count, 60) + XCTAssertEqual(payload.prefix(kotlinFixedNonce.count), kotlinFixedNonce) + XCTAssertEqual(decrypted, kotlinLoginKey) + XCTAssertEqual(kotlinContractId.count, 32) + } + + func testRejectsInvalidLoginKeyLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.encryptLoginKey( + Data(repeating: 0x01, count: 31), + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + )) + } + + func testRejectsInvalidNonceLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: Data(repeating: 0x00, count: 11) + )) + } + + func testRejectsInvalidIdentityIdLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.deriveAuthPrivateKey( + loginKey: loginKey, + identityId: Data(repeating: 0x00, count: 31) + )) + XCTAssertThrowsError(try KeyExchangeCrypto.deriveEncryptionPrivateKey( + loginKey: loginKey, + identityId: Data(repeating: 0x00, count: 31) + )) + } + + func testRejectsInvalidPayloadLength() { + XCTAssertThrowsError(try KeyExchangeCrypto.decryptLoginKey( + Data(repeating: 0x00, count: 59), + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + )) + } + + func testTamperedTagFailsToDecrypt() throws { + var tampered = try KeyExchangeCrypto.encryptLoginKey( + loginKey, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub, + nonce: fixedNonce + ) + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + + XCTAssertThrowsError(try KeyExchangeCrypto.decryptLoginKey( + tampered, + walletEphemeralPriv: walletEphemeralPriv, + appEphemeralPub: appEphemeralPub + )) + } +} diff --git a/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift b/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift new file mode 100644 index 000000000..a9b17a962 --- /dev/null +++ b/DashWalletTests/DashConnect/LoginKeyDerivationTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import dashpay + +final class LoginKeyDerivationTests: XCTestCase { + private let chainKey = Data(hex: "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")! + private let identityId = Data(hex: "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")! + private let appContractId = Data(hex: "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")! + + func testDefaultKeyIndexIsZero() { + XCTAssertEqual(LoginKeyDerivation.defaultKeyIndex, 0) + } + + func testDeriveLoginKeyMatchesVector() throws { + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(loginKey.hexEncodedString(), "0c6ee8098b3618e2cebe7a5fb4a5b3307eb84f410262489fedc386eca0cd882d") + } + + func testDeriveLoginKeyIsDeterministic() throws { + let first = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let second = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(first, second) + } + + func testDeriveLoginKeyChangesWhenAnyInputChanges() throws { + let baseline = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let differentChainKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: Data(hex: "616162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")!, + identityId: identityId, + appContractId: appContractId + ) + let differentIdentity = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: Data(hex: "414142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")!, + appContractId: appContractId + ) + let differentContract = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: Data(hex: "818182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")! + ) + + XCTAssertNotEqual(baseline, differentChainKey) + XCTAssertNotEqual(baseline, differentIdentity) + XCTAssertNotEqual(baseline, differentContract) + } + + func testRejectsInvalidLengths() { + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: Data(repeating: 0x00, count: 31), + identityId: identityId, + appContractId: appContractId + )) + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: Data(repeating: 0x00, count: 31), + appContractId: appContractId + )) + XCTAssertThrowsError(try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: Data(repeating: 0x00, count: 31) + )) + } +} diff --git a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift new file mode 100644 index 000000000..6f45b51dd --- /dev/null +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -0,0 +1,911 @@ +import XCTest +@testable import dashpay +@testable import SwiftDashSDK + +final class PlatformDashConnectDataSourceTests: XCTestCase { + func testSelectDocumentSigningKeyIdPrefersHighOverMaster() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 2, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 2) + } + + func testSelectDocumentSigningKeyIdFallsBackToCriticalWhenHighMissing() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 3, securityLevel: .critical), + ] + ) + + XCTAssertEqual(selected, 3) + } + + func testSelectDocumentSigningKeyIdPrefersHighOverCritical() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .master), + makeSigningCandidate(keyId: 4, securityLevel: .critical), + makeSigningCandidate(keyId: 5, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 5) + } + + func testSelectDocumentSigningKeyIdThrowsWhenOnlyMasterExists() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [makeSigningCandidate(keyId: 1, securityLevel: .master)] + ) + ) { error in + XCTAssertEqual(error as? DashConnectPlatformError, .noAuthenticationKey) + } + } + + func testSelectDocumentSigningKeyIdIgnoresDisabledEligibleKeys() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 1, securityLevel: .high, disabledAt: 123), + makeSigningCandidate(keyId: 2, securityLevel: .critical), + ] + ) + + XCTAssertEqual(selected, 2) + } + + func testSelectDocumentSigningKeyIdBreaksTiesByLowestKeyId() throws { + let selected = try PlatformDashConnectDataSource.selectDocumentSigningKeyId( + from: [ + makeSigningCandidate(keyId: 9, securityLevel: .high), + makeSigningCandidate(keyId: 4, securityLevel: .high), + makeSigningCandidate(keyId: 7, securityLevel: .high), + ] + ) + + XCTAssertEqual(selected, 4) + } + + func testMakeConnectionRequestCarriesExistingConnectionForMatchingContract() async { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + XCTAssertEqual(request.appContractId, existing.id) + } + + func testMakeConnectionRequestCarriesApprovedConnection() async { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + + let request = await dataSource.makeConnectionRequest(from: MockDashConnectDataSource.sampleLoginRequest) + + XCTAssertEqual(request.existingConnection, existing) + } + + func testMakeConnectionRequestReturnsNilForUnknownContract() async throws { + let existing = DAppConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + name: "Yappr", + url: "yap.pr", + status: .active, + updatedAt: Date(timeIntervalSince1970: 1_773_132_300) + ) + let dataSource = PlatformDashConnectDataSource( + store: TestDashConnectStore(initialConnections: [existing]) + ) + let request = try makeLoginRequest( + contractId: Data(repeating: 0x44, count: 32), + label: "Something else" + ) + + let connectionRequest = await dataSource.makeConnectionRequest(from: request) + + XCTAssertNil(connectionRequest.existingConnection) + } + + func testRealKeyRegistrationFixtureParsesWithNilContractBounds() throws { + let wallet = ManagedPlatformWallet(handle: 0, walletId: Data(repeating: 0x00, count: 32)) + let bytes = try XCTUnwrap(Data(hex: Self.realKeyRegistrationFixtureHex)) + let expectedIdentityId = try XCTUnwrap( + Data(hex: "89fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae72") + ) + let expectedAuthHash160 = try XCTUnwrap( + Data(hex: "5e24e38a86e720f61757647996957e322686abb7") + ) + let expectedEncryptionPublicKey = try XCTUnwrap( + Data(hex: "035e8cfb0785b54e8902a3dc17bdaad8a5738c6019a18ebc527f79d1c64a27826a") + ) + + let parsed = try wallet.parseIdentityUpdateTransition(bytes) + + XCTAssertEqual(parsed.identityId, expectedIdentityId) + XCTAssertEqual(parsed.disablePublicKeyIds, []) + XCTAssertEqual(parsed.addPublicKeys.count, 2) + + let authKey = parsed.addPublicKeys[0] + XCTAssertEqual(authKey.keyId, 6) + XCTAssertEqual(authKey.purpose, .authentication) + XCTAssertEqual(authKey.securityLevel, .high) + XCTAssertEqual(authKey.keyType, .ecdsaHash160) + XCTAssertNil(authKey.contractBounds) + XCTAssertEqual(authKey.pubkeyBytes, expectedAuthHash160) + + let encryptionKey = parsed.addPublicKeys[1] + XCTAssertEqual(encryptionKey.keyId, 7) + XCTAssertEqual(encryptionKey.purpose, .encryption) + XCTAssertEqual(encryptionKey.securityLevel, .medium) + XCTAssertEqual(encryptionKey.keyType, .ecdsaSecp256k1) + XCTAssertNil(encryptionKey.contractBounds) + XCTAssertEqual(encryptionKey.pubkeyBytes, expectedEncryptionPublicKey) + } + + func testPlatformWalletParserRoundTripsTaggedAndTaglessIdentityUpdateTransition() throws { + let wallet = ManagedPlatformWallet(handle: 0, walletId: Data(repeating: 0x00, count: 32)) + let taggedBytes = try XCTUnwrap(Data(base64Encoded: Self.taggedIdentityUpdateFixtureBase64)) + let taglessBytes = try XCTUnwrap(Data(base64Encoded: Self.taglessIdentityUpdateFixtureBase64)) + + let tagged = try wallet.parseIdentityUpdateTransition(taggedBytes) + let tagless = try wallet.parseIdentityUpdateTransition(taglessBytes) + + XCTAssertEqual(tagged.identityId, Data(repeating: 0x11, count: 32)) + XCTAssertEqual(tagged.addPublicKeys.map(\.keyId), [17, 18]) + XCTAssertEqual(tagged.disablePublicKeyIds, [4, 8]) + XCTAssertEqual(tagged.addPublicKeys[0].purpose, .authentication) + XCTAssertEqual(tagged.addPublicKeys[1].purpose, .encryption) + XCTAssertEqual( + tagged.addPublicKeys[1].contractBounds, + .singleContractDocumentType( + id: Data(repeating: 0x44, count: 32), + documentTypeName: "profile" + ) + ) + + XCTAssertEqual(tagless.identityId, tagged.identityId) + XCTAssertEqual(tagless.addPublicKeys.map(\.keyId), tagged.addPublicKeys.map(\.keyId)) + XCTAssertEqual(tagless.disablePublicKeyIds, tagged.disablePublicKeyIds) + + let appParser = PlatformWalletDashConnectKeyRegistrationParser { bytes in + try wallet.parseIdentityUpdateTransition(bytes) + } + let appTransition = try appParser.parse(taglessBytes) + XCTAssertEqual(appTransition.identityId, tagged.identityId) + XCTAssertEqual(appTransition.addPublicKeys.map(\.keyId), [17, 18]) + XCTAssertEqual(appTransition.disablePublicKeyIds, [4, 8]) + } + + func testBuildLoginKeyResponseDraftProducesExactFieldsAndWipesEphemeralPrivateKey() throws { + let loginKey = Data(repeating: 0x11, count: 32) + let appContractId = Data(repeating: 0xcd, count: 32) + let appEphemeralPubKey = try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)) + var walletEphemeralPrivateKey = Data(repeating: 0x02, count: 32) + let fixedPayload = Data(repeating: 0xaa, count: 60) + + let draft = try PlatformDashConnectDataSource.buildLoginKeyResponseDraft( + loginKey: loginKey, + appContractId: appContractId, + appEphemeralPubKey: appEphemeralPubKey, + walletEphemeralPrivateKey: &walletEphemeralPrivateKey, + encryptLoginKey: { loginKeyArg, walletPrivArg, appPubArg in + XCTAssertEqual(loginKeyArg, loginKey) + XCTAssertEqual(walletPrivArg, Data(repeating: 0x02, count: 32)) + XCTAssertEqual(appPubArg, appEphemeralPubKey) + return fixedPayload + } + ) + + XCTAssertEqual( + draft.properties["contractId"]?.base as? String, + appContractId.toBase58String() + ) + XCTAssertEqual( + draft.properties["appEphemeralPubKeyHash"]?.base as? String, + KeyExchangeCrypto.hash160(appEphemeralPubKey).toHexString() + ) + XCTAssertEqual( + draft.properties["walletEphemeralPubKey"]?.base as? String, + try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x02, count: 32)).toHexString() + ) + XCTAssertEqual( + draft.properties["encryptedPayload"]?.base as? String, + fixedPayload.toHexString() + ) + XCTAssertEqual(draft.properties["keyIndex"]?.base as? Int, LoginKeyDerivation.defaultKeyIndex) + XCTAssertEqual(draft.encryptedPayload.count, 60) + XCTAssertEqual(walletEphemeralPrivateKey, Data(repeating: 0x00, count: 32)) + } + + func testValidateKeyRegistrationRejectsTransitionForDifferentIdentity() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: Data(repeating: 0x55, count: 32), + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationWrongIdentity = error else { + return XCTFail("Expected wrong identity error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAttackerAddedKey() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let attackerPublicKey = try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x66, count: 32)) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: transition.addPublicKeys[0].keyType, + purpose: transition.addPublicKeys[0].purpose, + securityLevel: transition.addPublicKeys[0].securityLevel, + publicKeyData: KeyExchangeCrypto.hash160(attackerPublicKey), + contractBounds: transition.addPublicKeys[0].contractBounds + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected mismatched authentication key error, got \(error)") + } + } + } + + func testValidateKeyRegistrationAcceptsNilContractBoundsWhenDerivedKeysMatch() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + XCTAssertEqual(validated.authenticationKey, transition.addPublicKeys[0]) + XCTAssertEqual(validated.encryptionKey, transition.addPublicKeys[1]) + } + + func testValidateKeyRegistrationRejectsDerivedKeysForDifferentContract() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let transitionContractId = Data(repeating: 0x44, count: 32) + let actualContractId = Data(repeating: 0x55, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: transitionContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: actualContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey = error else { + return XCTFail("Expected mismatched derived key error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAuthenticationKeyUsingRawSecp256k1Encoding() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: .ecdsaSecp256k1, + purpose: .authentication, + securityLevel: .high, + publicKeyData: authPublicKey, + contractBounds: nil + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected authentication mismatch error, got \(error)") + } + } + } + + func testValidateKeyRegistrationRejectsAuthenticationKeyUsingRawPublicKeyData() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + var transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: identityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + transition = DashConnectKeyRegistrationTransition( + identityId: transition.identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: transition.addPublicKeys[0].keyId, + keyType: .ecdsaHash160, + purpose: .authentication, + securityLevel: .high, + publicKeyData: authPublicKey, + contractBounds: nil + ), + transition.addPublicKeys[1], + ], + disablePublicKeyIds: [] + ) + + XCTAssertThrowsError( + try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + ) { error in + guard let error = error as? DashConnectPlatformError, + case .keyRegistrationMismatchedDerivedKey(.authentication) = error else { + return XCTFail("Expected authentication mismatch error, got \(error)") + } + } + } + + func testPendingApprovedConnectionThrowsWhenNothingAwaitsKeyRegistration() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration( + in: [ + DAppConnection( + id: "A", + name: "Active App", + url: "active.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 10) + ), + DAppConnection( + id: "B", + name: "Another Active App", + url: "active-two.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 20) + ), + ] + ) + ) { error in + XCTAssertEqual(error as? DashConnectPlatformError, .noApprovedConnectionAwaitingKeyRegistration) + } + } + + func testPendingApprovedConnectionErrorTellsUserToScanLoginQrFirst() { + XCTAssertThrowsError( + try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration(in: []) + ) { error in + XCTAssertEqual( + error.localizedDescription, + "There is no login awaiting key registration — scan the app's login QR first." + ) + } + } + + func testPendingApprovedConnectionPrefersMostRecentApproved() throws { + let olderApproved = DAppConnection( + id: "older", + name: "Older Approved", + url: "older.app", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 10) + ) + let newestApproved = DAppConnection( + id: "newest", + name: "Newest Approved", + url: "newest.app", + status: .approved, + updatedAt: Date(timeIntervalSince1970: 30) + ) + let active = DAppConnection( + id: "active", + name: "Active", + url: "active.app", + status: .active, + updatedAt: Date(timeIntervalSince1970: 40) + ) + + let selected = try PlatformDashConnectDataSource.pendingApprovedConnectionForKeyRegistration( + in: [olderApproved, active, newestApproved] + ) + + XCTAssertEqual(selected, newestApproved) + } + + func testMissingKeyRegistrationKeysReturnsEmptyWhenBothKeysAlreadyRegistered() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [] + ) + } + + func testHasRegisteredLoginKeysReturnsTrueWhenBothKeysAreRegistered() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertTrue( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testMissingKeyRegistrationKeysReturnsOnlyMissingEncryptionKey() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ) + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [validated.encryptionKey] + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenAuthenticationKeyIsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testMissingKeyRegistrationKeysTreatsDisabledMatchingKeyAsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData, + disabledAt: 123 + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertEqual( + PlatformDashConnectDataSource.missingKeyRegistrationKeys( + validated, + currentIdentityPublicKeys: currentKeys + ), + [validated.authenticationKey] + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenEncryptionKeyIsMissing() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + func testHasRegisteredLoginKeysReturnsFalseWhenMatchingKeyIsDisabled() throws { + let chainKey = Data(repeating: 0x22, count: 32) + let identityId = Data(repeating: 0x33, count: 32) + let appContractId = Data(repeating: 0x44, count: 32) + let transition = try makeValidTransition( + identityId: identityId, + appContractId: appContractId, + chainKey: chainKey, + walletIdentityId: identityId + ) + let validated = try PlatformDashConnectDataSource.validateKeyRegistration( + transition, + chainKeyPrivateBytes: chainKey, + identityId: identityId, + appContractId: appContractId + ) + + let currentKeys = [ + makeIdentityPublicKeyInfo( + keyId: 100, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaHash160, + data: validated.authenticationKey.publicKeyData, + disabledAt: 123 + ), + makeIdentityPublicKeyInfo( + keyId: 101, + purpose: .encryption, + securityLevel: .medium, + keyType: .ecdsaSecp256k1, + data: validated.encryptionKey.publicKeyData + ), + ] + + XCTAssertFalse( + PlatformDashConnectDataSource.hasRegisteredLoginKeys( + authenticationPublicKeyHash160: validated.authenticationKey.publicKeyData, + encryptionPublicKey: validated.encryptionKey.publicKeyData, + currentIdentityPublicKeys: currentKeys + ) + ) + } + + private func makeValidTransition( + identityId: Data, + appContractId: Data, + chainKey: Data, + walletIdentityId: Data + ) throws -> DashConnectKeyRegistrationTransition { + let loginKey = try LoginKeyDerivation.deriveLoginKey( + chainKeyPrivateBytes: chainKey, + identityId: walletIdentityId, + appContractId: appContractId + ) + let authPrivateKey = try KeyExchangeCrypto.deriveAuthPrivateKey(loginKey: loginKey, identityId: walletIdentityId) + let encryptionPrivateKey = try KeyExchangeCrypto.deriveEncryptionPrivateKey(loginKey: loginKey, identityId: walletIdentityId) + let authPublicKey = try Secp256k1.compressedPublicKey(privateKey: authPrivateKey) + + return DashConnectKeyRegistrationTransition( + identityId: identityId, + addPublicKeys: [ + DashConnectKeyRegistrationKey( + keyId: 6, + keyType: .ecdsaHash160, + purpose: .authentication, + securityLevel: .high, + publicKeyData: KeyExchangeCrypto.hash160(authPublicKey), + contractBounds: nil + ), + DashConnectKeyRegistrationKey( + keyId: 7, + keyType: .ecdsaSecp256k1, + purpose: .encryption, + securityLevel: .medium, + publicKeyData: try Secp256k1.compressedPublicKey(privateKey: encryptionPrivateKey), + contractBounds: nil + ), + ], + disablePublicKeyIds: [] + ) + } + + private func makeLoginRequest(contractId: Data, label: String) throws -> DashKeyRequest { + DashKeyRequest( + appEphemeralPubKey: try Secp256k1.compressedPublicKey(privateKey: Data(repeating: 0x01, count: 32)), + contractId: contractId, + label: label, + network: .testnet + ) + } + + private func makeSigningCandidate( + keyId: UInt32, + securityLevel: SecurityLevel, + purpose: KeyPurpose = .authentication, + keyType: KeyType = .ecdsaSecp256k1, + disabledAt: Int64? = nil + ) -> PlatformDashConnectDataSource.DocumentSigningKeyCandidate { + PlatformDashConnectDataSource.DocumentSigningKeyCandidate( + keyId: keyId, + purpose: purpose, + securityLevel: securityLevel, + keyType: keyType, + disabledAt: disabledAt + ) + } + + private func makeIdentityPublicKeyInfo( + keyId: UInt32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + data: Data, + disabledAt: UInt64? = nil + ) -> ManagedIdentity.IdentityPublicKeyInfo { + ManagedIdentity.IdentityPublicKeyInfo( + keyId: Int32(bitPattern: keyId), + purpose: purpose, + securityLevel: securityLevel, + keyType: keyType, + readOnly: false, + disabledAt: disabledAt.map { Int64(bitPattern: $0) }, + data: data + ) + } + + private static let taggedIdentityUpdateFixtureBase64 = + "BgAREREREREREREREREREREREREREREREREREREREREREQcJAgARAAACAAAhAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICQaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqABIAAQIBAUREREREREREREREREREREREREREREREREREREREREREB3Byb2ZpbGUBIQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA0G7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7uwIECAIDQZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZ" + + private static let taglessIdentityUpdateFixtureBase64 = + "ABERERERERERERERERERERERERERERERERERERERERERBwkCABEAAAIAACECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgJBqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEgABAgEBREREREREREREREREREREREREREREREREREREREREREQHcHJvZmlsZQEhAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDQbu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7AgQIAgNBmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZk=" + + private static let realKeyRegistrationFixtureHex = + "0089fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae7201010200060200020000145e24e38a86e720f61757647996957e322686abb7000007000103000021035e8cfb0785b54e8902a3dc17bdaad8a5738c6019a18ebc527f79d1c64a27826a4120dc911df1d1e6cccf8c95ec0d423c928433397933de6dd9ad006bc40dc0334d6d270d50c6d5e2dcdc5560e40487ddfe28bd1066d0729fad4b26f92ae33f12a04b00000000" +} + +private final class TestDashConnectStore: DashConnectStore { + private var connections: [DAppConnection] + + init(initialConnections: [DAppConnection] = []) { + self.connections = initialConnections + } + + func load() -> [DAppConnection] { + connections + } + + func save(_ connections: [DAppConnection]) { + self.connections = connections + } +} diff --git a/DashWalletTests/DashConnect/Secp256k1Tests.swift b/DashWalletTests/DashConnect/Secp256k1Tests.swift new file mode 100644 index 000000000..76eeacf0c --- /dev/null +++ b/DashWalletTests/DashConnect/Secp256k1Tests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import dashpay + +final class Secp256k1Tests: XCTestCase { + private enum CandidateError: Error { + case notFound + } + + private let expectedPublicKeyA = Data(hex: "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f")! + + func testCompressedPublicKeyMatchesExpectedHex() throws { + let publicKey = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + XCTAssertEqual(publicKey, expectedPublicKeyA) + } + + func testIsValidCompressedPointAcceptsKnownGoodPointAndRejectsMalformedInputs() { + XCTAssertTrue(Secp256k1.isValidCompressedPoint(expectedPublicKeyA)) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(Data(expectedPublicKeyA.dropLast()))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(expectedPublicKeyA, Data([0x00])))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(Data([0x04]), Data(expectedPublicKeyA.dropFirst())))) + XCTAssertFalse(Secp256k1.isValidCompressedPoint(appending(Data([0x02]), Data(repeating: 0x00, count: 32)))) + } + + func testECDHIsSymmetric() throws { + let publicKeyA = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + let publicKeyB = try Secp256k1.compressedPublicKey(privateKey: privateKeyB) + + let sharedAB = try Secp256k1.ecdhSharedX(privateKey: privateKeyA, publicKey: publicKeyB) + let sharedBA = try Secp256k1.ecdhSharedX(privateKey: privateKeyB, publicKey: publicKeyA) + + XCTAssertEqual(sharedAB, sharedBA) + XCTAssertEqual(sharedAB.count, 32) + } + + func testECDHSharedXStaysLeftPaddedTo32Bytes() throws { + let candidate = try findLeadingZeroSharedXCandidate() + let shared = try Secp256k1.ecdhSharedX( + privateKey: candidate.privateKey, + publicKey: candidate.publicKey + ) + + XCTAssertEqual(shared.count, 32) + XCTAssertEqual(shared.first, 0x00) + } + + private func findLeadingZeroSharedXCandidate() throws -> (privateKey: Data, publicKey: Data) { + for a in UInt16(1)...UInt16(128) { + let privateKeyA = makePrivateKey(a) + let publicKeyA = try Secp256k1.compressedPublicKey(privateKey: privateKeyA) + + for b in UInt16(129)...UInt16(256) { + let privateKeyB = makePrivateKey(b) + let publicKeyB = try Secp256k1.compressedPublicKey(privateKey: privateKeyB) + let sharedAB = try Secp256k1.ecdhSharedX(privateKey: privateKeyA, publicKey: publicKeyB) + let sharedBA = try Secp256k1.ecdhSharedX(privateKey: privateKeyB, publicKey: publicKeyA) + + if sharedAB.first == 0x00 { + XCTAssertEqual(sharedAB, sharedBA) + return (privateKeyA, publicKeyB) + } + } + } + + XCTFail("Expected to find a deterministic ECDH pair whose shared X starts with 0x00.") + throw CandidateError.notFound + } + + private var privateKeyA: Data { + Data(repeating: 0x01, count: 32) + } + + private var privateKeyB: Data { + Data(repeating: 0x02, count: 32) + } + + private func makePrivateKey(_ value: UInt16) -> Data { + var bytes = Data(repeating: 0x00, count: 32) + bytes[30] = UInt8(value >> 8) + bytes[31] = UInt8(value & 0xff) + return bytes + } + + private func appending(_ lhs: Data, _ rhs: Data) -> Data { + var result = lhs + result.append(rhs) + return result + } +} diff --git a/DashWalletTests/DashPayIdentityKeysTests.swift b/DashWalletTests/DashPayIdentityKeysTests.swift index 8bc38eef2..6d46f3060 100644 --- a/DashWalletTests/DashPayIdentityKeysTests.swift +++ b/DashWalletTests/DashPayIdentityKeysTests.swift @@ -6,7 +6,7 @@ import Foundation import SwiftDashSDK import XCTest -@testable import dashwallet +@testable import dashpay final class DashPayIdentityKeysTests: XCTestCase { diff --git a/DashWalletTests/DiagnosticLogExporterTests.swift b/DashWalletTests/DiagnosticLogExporterTests.swift index 821091b1c..396e04a5d 100644 --- a/DashWalletTests/DiagnosticLogExporterTests.swift +++ b/DashWalletTests/DiagnosticLogExporterTests.swift @@ -30,7 +30,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class DiagnosticLogExporterTests: XCTestCase { private func candidate(_ stamp: String, bytes: UInt64) -> DiagnosticLogExporter.SessionCandidate { diff --git a/DashWalletTests/ExchangeAddressLookupContextTests.swift b/DashWalletTests/ExchangeAddressLookupContextTests.swift index 753e11b4d..fa549ffce 100644 --- a/DashWalletTests/ExchangeAddressLookupContextTests.swift +++ b/DashWalletTests/ExchangeAddressLookupContextTests.swift @@ -18,7 +18,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay final class ExchangeAddressLookupContextTests: XCTestCase { diff --git a/DashWalletTests/PastedAmountNormalizationTests.swift b/DashWalletTests/PastedAmountNormalizationTests.swift index 8d9b0f1a1..bc8bfd5a4 100644 --- a/DashWalletTests/PastedAmountNormalizationTests.swift +++ b/DashWalletTests/PastedAmountNormalizationTests.swift @@ -15,7 +15,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay /// Tests for `BaseAmountModel.normalizedPastedNumberString(from:)`, which makes pasting /// amounts work regardless of the decimal/grouping convention of the pasted text or the diff --git a/DashWalletTests/PaymentProtocolTests.swift b/DashWalletTests/PaymentProtocolTests.swift index e71f23545..0e7813e03 100644 --- a/DashWalletTests/PaymentProtocolTests.swift +++ b/DashWalletTests/PaymentProtocolTests.swift @@ -14,7 +14,7 @@ import XCTest import Security import SwiftDashSDK -@testable import dashwallet +@testable import dashpay final class PaymentProtocolTests: XCTestCase { diff --git a/DashWalletTests/PhraseRepairEngineTests.swift b/DashWalletTests/PhraseRepairEngineTests.swift index dbedd5838..35a55bfc3 100644 --- a/DashWalletTests/PhraseRepairEngineTests.swift +++ b/DashWalletTests/PhraseRepairEngineTests.swift @@ -20,7 +20,7 @@ import XCTest import SwiftDashSDK -@testable import dashwallet +@testable import dashpay // MARK: - Helpers diff --git a/DashWalletTests/String+DashWalletTests.swift b/DashWalletTests/String+DashWalletTests.swift index bee289c4f..eb874cae6 100644 --- a/DashWalletTests/String+DashWalletTests.swift +++ b/DashWalletTests/String+DashWalletTests.swift @@ -16,7 +16,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay // MARK: - String_DashWallet diff --git a/DashWalletTests/SwapAddressValidatorTests.swift b/DashWalletTests/SwapAddressValidatorTests.swift index b9441f919..2c8c06b3d 100644 --- a/DashWalletTests/SwapAddressValidatorTests.swift +++ b/DashWalletTests/SwapAddressValidatorTests.swift @@ -18,7 +18,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay class SwapAddressValidatorTests: XCTestCase { diff --git a/DashWalletTests/SwapKitQuoteDecodingTests.swift b/DashWalletTests/SwapKitQuoteDecodingTests.swift index 1ba20e32e..8d3fd6d96 100644 --- a/DashWalletTests/SwapKitQuoteDecodingTests.swift +++ b/DashWalletTests/SwapKitQuoteDecodingTests.swift @@ -17,7 +17,7 @@ // limitations under the License. // -@testable import dashwallet +@testable import dashpay import XCTest final class SwapKitQuoteDecodingTests: XCTestCase { diff --git a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift index c741937c2..349f16234 100644 --- a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift +++ b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift @@ -6,7 +6,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay private enum CoreLifecycleTestError: Error { case start diff --git a/DashWalletTests/WalletWipeSerialExecutorTests.swift b/DashWalletTests/WalletWipeSerialExecutorTests.swift index 6cc980d7c..af8bc9670 100644 --- a/DashWalletTests/WalletWipeSerialExecutorTests.swift +++ b/DashWalletTests/WalletWipeSerialExecutorTests.swift @@ -8,7 +8,7 @@ // import XCTest -@testable import dashwallet +@testable import dashpay private enum WalletLifecycleTestError: Error { case walletDeletion From e184b852fa176e8b1edac3cadd97869d11112849 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:29 +0300 Subject: [PATCH 4/5] fix(dashpay): make the DashConnect switch work and match Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device testing found the row's switch did nothing, and reading the Android original showed the behaviour behind it was wrong too. **The switch never received the tap.** DashUIKit's `SwitchView` owns its gesture at its intrinsic 64×28 geometry, but the row scaled it with `scaleEffect(0.75)` inside a smaller outer frame — `scaleEffect` changes only the drawing, so the tap region no longer lined up with what the user saw. The switch is now a non-interactive indicator and the row owns the gesture over the whole visible area, which also removes the `Binding(get: { true })` workaround. **Turning it off returns the row to `approved`, not deletion.** That matches `PlatformDashConnectRepository.disconnect` on Android and Figma 5805:51555: the post-toggle state is "Approved" plus the scan-to-log-in banner, with no confirmation dialog — the toggle itself is the action. This also closes the dead end found on device: after signing out on the app's website the user taps the switch, rescans the `dash-key:` QR, and the key check from the previous commit puts the row straight back to `active`. `approved` means "awaiting login", which is exactly what a logged-out connection is; the earlier reading of it as "keys not registered" was too narrow. Also fixes the `Active` row wrapping its timestamp: the status badge carried `maxWidth: .infinity` as well, so it split the row with the name column and was capped below the width the date needs. It now takes its natural width, with Android's 12pt gap, and both columns are single-line. Co-Authored-By: Claude Opus 5 --- .../DashConnect/DashConnectDataSource.swift | 15 +++++++ .../DashConnect/DashConnectModels.swift | 7 ++++ .../PlatformDashConnectDataSource.swift | 17 ++++++++ .../Components/ConnectionRow.swift | 39 ++++++++++--------- .../Components/ConnectionStatusBadge.swift | 7 +++- .../Components/ConnectionsList.swift | 6 +-- .../UI/DashConnect/ConnectionsScreen.swift | 38 +----------------- .../UI/DashConnect/ConnectionsViewModel.swift | 12 ++---- .../DashConnect/DashConnectStoreTests.swift | 21 ++++++++++ 9 files changed, 95 insertions(+), 67 deletions(-) diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift index 72fc1f0a7..b628d6f95 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -27,6 +27,7 @@ protocol DashConnectDataSource { func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection func completeKeyRegistration(_ request: DashStRequest) async throws + func disconnect(id: String) async func remove(id: String) async } @@ -195,6 +196,20 @@ final class MockDashConnectDataSource: DashConnectDataSource { throw DashConnectMockError.keyRegistrationNotSupported } + func disconnect(id: String) async { + let disconnectedAt = Date() + persistAndSend(subject.value.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .approved, + updatedAt: disconnectedAt + ) + }) + } + func remove(id: String) async { persistAndSend(subject.value.filter { $0.id != id }) } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift index 48e8665b0..b42100950 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -26,6 +26,13 @@ enum DashConnectQr: Equatable { } /// Lifecycle of an app connection. +/// +/// `approved` means the wallet knows about the app, but it may not be ready to +/// sign in yet: either the required login keys are not on the identity yet, or +/// the user locally turned the connection off from the wallet UI. +/// +/// `active` means the app's login keys are registered on the identity and the +/// wallet has already published the matching `loginKeyResponse`. enum ConnectionStatus: String, Codable, CaseIterable { case approved case active diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 136f446f0..0b21f0a66 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -556,6 +556,23 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { persistAndSend(subject.value.filter { $0.id != id }) } + func disconnect(id: String) async { + let disconnectedAt = now() + // Local-only: keep the published `loginKeyResponse` document intact. + // Re-approving the same `dash-key:` QR is enough to return to `.active` + // once the identity keys are still registered. + persistAndSend(subject.value.map { connection in + guard connection.id == id else { return connection } + return DAppConnection( + id: connection.id, + name: connection.name, + url: connection.url, + status: .approved, + updatedAt: disconnectedAt + ) + }) + } + static func buildLoginKeyResponseDraft( loginKey: Data, appContractId: Data, diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift index 9c6cf637c..ca4b69ebf 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionRow.swift @@ -30,7 +30,7 @@ struct ConnectionRow: View { let connection: DAppConnection let onPrimaryAction: () -> Void let onMockScan: () -> Void - let onRemove: () -> Void + let onDisconnect: () -> Void private enum Layout { /// `SwitchView` is a fixed 64×28 from the design system and sets that @@ -45,11 +45,11 @@ struct ConnectionRow: View { var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 20) { + HStack(spacing: 12) { identity ConnectionStatusBadge(status: connection.status, updatedAt: connection.updatedAt) - .frame(maxWidth: .infinity, alignment: .topTrailing) + .fixedSize(horizontal: true, vertical: false) if connection.status == .active { activeSwitch @@ -70,31 +70,34 @@ struct ConnectionRow: View { Text(connection.name) .dashFont(.subhead) .foregroundStyle(Color.dash.primaryText) + .lineLimit(1) if !connection.url.isEmpty { Text(connection.url) .dashFont(.footnote) .foregroundStyle(Color.dash.tertiaryText) + .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .topLeading) } /// Always reads as on: the row only shows it for an active connection, and - /// only the off direction is reachable from here. Turning it off removes - /// this wallet's record of the connection; it does not sign the user out - /// of the app, because the wallet has no channel to end that session. + /// only the off direction is reachable from here. Turning it off locally + /// downgrades the connection back to `.approved`; it does not sign the user + /// out of the app, because the wallet has no channel to end that session. private var activeSwitch: some View { - SwitchView(isOn: Binding( - get: { true }, - set: { isOn in - if !isOn { - onRemove() - } - } - )) - .scaleEffect(Layout.switchScale) + ZStack { + SwitchView(isOn: .constant(true)) + .allowsHitTesting(false) + .scaleEffect(Layout.switchScale) + } .frame(width: Layout.switchWidth, height: Layout.switchHeight) + .contentShape(Rectangle()) + .onTapGesture(perform: onDisconnect) + .accessibilityElement() + .accessibilityAddTraits(.isButton) + .accessibilityLabel(Text(NSLocalizedString("Disconnect", comment: "DashConnect"))) } } @@ -104,14 +107,14 @@ struct ConnectionRow: View { ConnectionRow( connection: DAppConnection( id: status.rawValue, - name: "Example App", + name: "A Very Long Application Name Indeed", url: "https://example.org", status: status, - updatedAt: Date() + updatedAt: Date(timeIntervalSince1970: 1_754_232_520) ), onPrimaryAction: {}, onMockScan: {}, - onRemove: {} + onDisconnect: {} ) } } diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift index 783353c85..1883126f1 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionStatusBadge.swift @@ -43,11 +43,13 @@ struct ConnectionStatusBadge: View { Text(title) .dashFont(.footnote) .foregroundColor(Color.dash.primaryText) + .lineLimit(1) } Text(Self.dateFormatter.string(from: updatedAt)) .dashFont(.footnote) .foregroundColor(Color.dash.tertiaryText) + .lineLimit(1) } } @@ -70,7 +72,10 @@ struct ConnectionStatusBadge: View { #Preview { VStack(alignment: .trailing, spacing: 16) { ForEach(ConnectionStatus.allCases, id: \.self) { status in - ConnectionStatusBadge(status: status, updatedAt: Date()) + ConnectionStatusBadge( + status: status, + updatedAt: Date(timeIntervalSince1970: 1_754_232_520) + ) } } .padding() diff --git a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift index 377850213..2fbfea899 100644 --- a/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift +++ b/DashWallet/Sources/UI/DashConnect/Components/ConnectionsList.swift @@ -26,7 +26,7 @@ struct ConnectionsList: View { let connections: [DAppConnection] let onScanQR: () -> Void let onMockScan: () -> Void - let onRemove: (DAppConnection) -> Void + let onDisconnect: (DAppConnection) -> Void var body: some View { ScrollView(showsIndicators: false) { @@ -36,7 +36,7 @@ struct ConnectionsList: View { connection: connection, onPrimaryAction: onScanQR, onMockScan: onMockScan, - onRemove: { onRemove(connection) } + onDisconnect: { onDisconnect(connection) } ) } } @@ -58,7 +58,7 @@ struct ConnectionsList: View { }, onScanQR: {}, onMockScan: {}, - onRemove: { _ in } + onDisconnect: { _ in } ) .padding(20) .background(Color.primaryBackground) diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift index 6c8272cef..b3212803d 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -49,9 +49,6 @@ struct ConnectionsScreen: View { .padding(.bottom, 20) content - .alert(item: pendingRemovalBinding) { connection in - removalAlert(for: connection) - } } if viewModel.isProcessingKeyRegistration { @@ -97,7 +94,7 @@ struct ConnectionsScreen: View { connections: viewModel.connections, onScanQR: showScanner, onMockScan: mockScan, - onRemove: viewModel.requestRemoval + onDisconnect: viewModel.disconnect ) .padding(.horizontal, 20) } @@ -134,13 +131,6 @@ struct ConnectionsScreen: View { ) } - private var pendingRemovalBinding: Binding { - Binding( - get: { viewModel.pendingRemoval }, - set: { viewModel.pendingRemoval = $0 } - ) - } - // MARK: - Actions private func showScanner() { @@ -161,32 +151,6 @@ struct ConnectionsScreen: View { private func mockScan() { viewModel.onQRScanned(MockDashConnectDataSource.sampleLoginQRCode) } - - private func removalAlert(for connection: DAppConnection) -> Alert { - Alert( - title: Text( - String( - format: NSLocalizedString("Disconnect %@?", comment: "DashConnect"), - connection.name - ) - ), - message: Text( - String( - format: NSLocalizedString( - "This removes the connection from this wallet. %@ may stay signed in until you sign out there.", - comment: "DashConnect" - ), - connection.name - ) - ), - primaryButton: .destructive(Text(NSLocalizedString("Disconnect", comment: "DashConnect"))) { - viewModel.removeConnection(connection) - }, - secondaryButton: .cancel { - viewModel.cancelPendingRemoval() - } - ) - } } // MARK: - Previews diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift index 161467eda..bf7b61c78 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -51,7 +51,6 @@ final class ConnectionsViewModel: ObservableObject { @Published var isApproving = false @Published var isProcessingKeyRegistration = false @Published var message: ConnectionsScreenMessage? - @Published var pendingRemoval: DAppConnection? /// Failure of the last approve attempt, rendered **inside** the approve sheet. /// A screen-level `.alert` cannot appear over a presented sheet, so routing this /// through `message` would leave the user with no feedback at all. @@ -142,16 +141,13 @@ final class ConnectionsViewModel: ObservableObject { approveError = nil } - func requestRemoval(_ connection: DAppConnection) { - pendingRemoval = connection - } - - func cancelPendingRemoval() { - pendingRemoval = nil + func disconnect(_ connection: DAppConnection) { + Task { + await dataSource.disconnect(id: connection.id) + } } func removeConnection(_ connection: DAppConnection) { - pendingRemoval = nil Task { await dataSource.remove(id: connection.id) } diff --git a/DashWalletTests/DashConnect/DashConnectStoreTests.swift b/DashWalletTests/DashConnect/DashConnectStoreTests.swift index b912cd798..38ee3b6d0 100644 --- a/DashWalletTests/DashConnect/DashConnectStoreTests.swift +++ b/DashWalletTests/DashConnect/DashConnectStoreTests.swift @@ -165,6 +165,27 @@ final class DashConnectStoreTests: XCTestCase { XCTAssertEqual(second.connectionsSnapshot, []) } + func testDisconnectPersistsApprovedStatusAcrossFreshMockInstances() async throws { + let store = makeStore(network: .testnet, walletIdHex: "wallet-a") + let first = MockDashConnectDataSource( + initial: [ + sampleConnection( + id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", + status: .active, + updatedAt: Date(timeIntervalSince1970: 10) + ) + ], + store: store + ) + + await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + + let second = MockDashConnectDataSource(store: store) + XCTAssertEqual(second.connectionsSnapshot.count, 1) + XCTAssertEqual(second.connectionsSnapshot.first?.id, "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F") + XCTAssertEqual(second.connectionsSnapshot.first?.status, .approved) + } + private func makeStore(network: DashConnectNetwork, walletIdHex: String?) -> UserDefaultsDashConnectStore { UserDefaultsDashConnectStore( defaults: defaults, From 08530f771ec91fc473e456b263d35fe529d20e8c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:54 +0300 Subject: [PATCH 5/5] fix(wallet): stop NetworkReachability from parking the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not DashConnect — this predates the branch, but the warning shows up on every launch of the QA build: Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Utility quality-of-service class. `startMonitoring()` blocked its caller on a semaphore only the monitor's own `.utility` queue could signal, and every caller is on the main thread (`DWHomeModel` init and `retrySyncing`, `startNetworkMonitoring`, `NetworkUnavailableStateView`). `DispatchSemaphore` does not propagate the waiter's QoS, so the main thread parked behind a utility-priority thread for up to 200 ms on each start — `retrySyncing` does `stop` + `start`. The wait could not simply be deleted: `startNetworkMonitoring` reads `isReachable` immediately afterwards, so removing it would flash the offline state until the first path notification arrived. The state is now seeded from `NWPathMonitor.currentPath`, which is readable right after `start(queue:)` — the synchronous contract holds with no blocking and no semaphore. The stale `DSReachabilityManager` justification in the comment goes with it; DashSync is long unlinked. Co-Authored-By: Claude Opus 5 --- .../Networking/NetworkReachability.swift | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift b/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift index 9479ad1e2..cd18b8810 100644 --- a/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift +++ b/DashWallet/Sources/Infrastructure/Networking/NetworkReachability.swift @@ -59,17 +59,15 @@ final class NetworkReachability: NSObject { hasReceivedFirstPath = false lock.unlock() - // Block until the first path update lands so callers observe real - // state the moment this method returns — matches `SCNetworkReachability`'s - // synchronous contract that `DSReachabilityManager` relied on. - let firstUpdate = DispatchSemaphore(value: 0) m.pathUpdateHandler = { [weak self] path in - if self?.handlePathUpdate(path) == true { - firstUpdate.signal() - } + self?.handlePathUpdate(path) } m.start(queue: queue) - _ = firstUpdate.wait(timeout: .now() + .milliseconds(200)) + + // Callers read `isReachable` the moment this returns, so the state is + // seeded from the path the monitor already holds instead of parking the + // caller until the utility-QoS queue delivers its first callback. + handlePathUpdate(m.currentPath) } @objc func stopMonitoring() { @@ -80,16 +78,15 @@ final class NetworkReachability: NSObject { m?.cancel() } - /// Returns `true` on the first path update after `startMonitoring()`. - @discardableResult - private func handlePathUpdate(_ path: NWPath) -> Bool { + /// Updates the shared reachability snapshot from the latest path and marks + /// whether the monitor has observed at least one path since it started. + private func handlePathUpdate(_ path: NWPath) { let reachable = path.status == .satisfied let wifi = reachable && path.usesInterfaceType(.wifi) lock.lock() _isReachable = reachable _isReachableViaWiFi = wifi - let wasFirst = !hasReceivedFirstPath hasReceivedFirstPath = true lock.unlock() @@ -99,6 +96,5 @@ final class NetworkReachability: NSObject { object: self ) } - return wasFirst } }