From d84a30685614355873fafdae1af05c34aae1b621 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:30:54 +0300 Subject: [PATCH 1/4] fix(wallet): log the two wallet-startup outcomes added by the SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WalletStartupStatus` gained `seedBindingUnverified` and `identityScanIncomplete`, so the DashPay readiness log stopped compiling. Both are logged as warnings rather than errors: unlike `discoveryFailed`, which is a local fault nothing will clear on its own, each of these is recoverable on a later start β€” a rerun with the matching signer completes the queued contact-account work, and an incomplete identity scan stays on record so the next launch re-scans instead of taking the warm shortcut. --- .../DashPayContactAddressReadiness.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift index d1e0c65f2..4dfe07c4e 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift @@ -94,6 +94,29 @@ enum DashPayContactAddressReadiness { πŸ‘₯ DP-READY :: identity discovery failed locally after \ \(seconds, privacy: .public)s; starting SPV without DashPay state """) + case .seedBindingUnverified: + // The signer handed to the call belongs to a different wallet, so + // the drain derived nothing rather than write contact addresses + // from the wrong seed. A rerun with the right signer completes the + // work, which stays queued β€” hence warning, not error. + logger.warning( + """ + πŸ‘₯ DP-READY :: contact crypto could not verify this wallet's seed \ + after \(seconds, privacy: .public)s; starting SPV, contact accounts \ + stay queued for a run with the matching signer + """) + case .identityScanIncomplete: + // Every later step ran for the identity that is known, but the + // gap-limit scan left indices unanswered, so the identity set is + // not established. The verdict stays on record and the next launch + // re-scans instead of taking the warm shortcut. + logger.warning( + """ + πŸ‘₯ DP-READY :: identity scan left indices unanswered after \ + \(seconds, privacy: .public)s \ + scans=\(outcome.discoveryAttempts, privacy: .public); \ + starting SPV, the next start re-scans + """) } } } From 80d8d8b08cd263d35b90fc50df89cd22a41dcfba Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:31:08 +0300 Subject: [PATCH 2/4] feat(dashpay): authorize a token purchase from a DashConnect link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `dash-st:` link was parsed strictly as an `IdentityUpdateTransition` β€” the DashConnect key-registration step β€” so a dApp asking the wallet to authorize a token purchase failed with "Expected IdentityUpdateTransition, got Batch(...)". Yappr's own fallback was to ask the user to paste a CRITICAL private key into a web page, which is exactly what DashConnect exists to avoid. The `dash-st` path now parses once through the SDK's new `parseStateTransition` and switches on the kind rather than on a thrown error. Identity updates keep today's key-registration behaviour unchanged, including its wrong-identity, unexpected-mutation and mismatched-key validation. A token purchase gets an approval sheet showing what is being authorized β€” token count, the total price rendered in DASH, and which identity of the user's will be charged β€” and only then signs. The wallet never signs the bytes it was handed. It reads the intent from them, shows it, and rebuilds the purchase through `tokenPurchase`, passing the parsed price as `expectedTotalCost` so the amount shown and the amount charged cannot diverge. Ownership is checked before the sheet is built and again immediately before signing, since the sheet can sit open while the active identity changes. Signing uses a CRITICAL key, which the signer selects itself; an identity without one fails with a visible message rather than a silent no-op. Two helpers shared with the connection sheet were promoted out of it rather than copied. --- .../DashConnect/DashConnectDataSource.swift | 23 +- .../DashConnect/DashConnectModels.swift | 47 +++- .../PlatformDashConnectDataSource.swift | 209 ++++++++++++++---- .../DashConnect/ApproveConnectionSheet.swift | 36 +-- .../ApproveTokenPurchaseSheet.swift | 206 +++++++++++++++++ .../UI/DashConnect/ConnectionsScreen.swift | 29 ++- .../UI/DashConnect/ConnectionsViewModel.swift | 65 +++++- .../DashConnectDataSourceTests.swift | 12 +- .../PlatformDashConnectDataSourceTests.swift | 8 +- 9 files changed, 543 insertions(+), 92 deletions(-) create mode 100644 DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift index b628d6f95..22b6b804d 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -26,7 +26,12 @@ protocol DashConnectDataSource { 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 + /// Parses a scanned `dash-st:` payload and either completes key + /// registration immediately or returns a token purchase that awaits + /// explicit user approval via `approveTokenPurchase(_:)`. + func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction + /// Rebuilds, signs and submits a token purchase the user approved. + func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws func disconnect(id: String) async func remove(id: String) async } @@ -36,7 +41,7 @@ enum DashConnectMockError: LocalizedError, Equatable { case approveFailed case notDashConnectQrCode case unsupportedNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) - case keyRegistrationNotSupported + case stateTransitionNotSupported var errorDescription: String? { switch self { @@ -48,8 +53,8 @@ enum DashConnectMockError: LocalizedError, Equatable { 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." + case .stateTransitionNotSupported: + return "State transitions are not supported by the mock." } } @@ -150,7 +155,7 @@ final class MockDashConnectDataSource: DashConnectDataSource { if DashConnectUri.isStUri(trimmed) { let request = try DashConnectUri.parseStRequest(trimmed) try validateNetwork(request.network) - return .keyRegistration(request) + return .stateTransition(request) } throw DashConnectMockError.notDashConnectQrCode @@ -191,9 +196,13 @@ final class MockDashConnectDataSource: DashConnectDataSource { ) } - func completeKeyRegistration(_ request: DashStRequest) async throws { + func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction { try await Task.sleep(nanoseconds: 400_000_000) - throw DashConnectMockError.keyRegistrationNotSupported + throw DashConnectMockError.stateTransitionNotSupported + } + + func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws { + throw DashConnectMockError.stateTransitionNotSupported } func disconnect(id: String) async { diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift index b42100950..022a60e2d 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -22,7 +22,52 @@ import SwiftDashSDK enum DashConnectQr: Equatable { case login(DashKeyRequest) - case keyRegistration(DashStRequest) + /// A `dash-st:` payload β€” a serialized state transition whose kind is + /// only known once the wallet parses it (key registration or token + /// purchase). + case stateTransition(DashStRequest) +} + +/// What the wallet did β€” or still needs the user to do β€” with a scanned +/// `dash-st:` state transition. +enum DashConnectStAction: Equatable { + /// Key registration was validated and published (or the derived keys + /// were already on the identity). + case keyRegistrationCompleted + /// The payload is a token purchase; nothing was signed or sent yet β€” it + /// awaits explicit user approval via `approveTokenPurchase(_:)`. + case tokenPurchaseApprovalRequired(DashConnectTokenPurchaseRequest) +} + +/// A pending token purchase parsed from a `dash-st:` payload, awaiting user +/// approval. Carries the raw values the purchase is rebuilt from and the +/// display fields the approval sheet renders. +struct DashConnectTokenPurchaseRequest: Equatable { + /// Name of an already-connected app whose contract id matches the + /// purchase's data contract, when one is stored locally. Display only. + let appName: String? + /// Identity the purchase debits β€” validated to be the wallet's own both + /// when the request is built and again on approve. + let ownerId: Data + let dataContractId: Data + let tokenId: Data + let tokenContractPosition: UInt16 + let tokenCount: UInt64 + /// Total price in Platform credits; passed to `tokenPurchase(...)` as + /// `expectedTotalCost` so the shown and charged amounts cannot diverge. + let totalAgreedPriceCredits: UInt64 + let walletUsername: String? + let walletIdentityId: String +} + +extension DashConnectTokenPurchaseRequest { + /// Platform credits per DASH (1e11 β€” 1e8 duffs x 1000 credits per duff). + static let creditsPerDash: Decimal = 100_000_000_000 + + /// The total price converted to DASH for display. + var totalPriceDash: Decimal { + Decimal(totalAgreedPriceCredits) / Self.creditsPerDash + } } /// Lifecycle of an app connection. diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 5c8361656..14130d772 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -68,6 +68,7 @@ enum DashConnectPlatformError: LocalizedError, Equatable { case keyRegistrationWrongIdentity case keyRegistrationUnexpectedMutation case keyRegistrationMismatchedDerivedKey(KeyPurpose) + case tokenPurchaseWrongIdentity case ephemeralKeyGenerationFailed case ambiguousKeyRegistrationConnection case devnetLoginContractNotConfigured @@ -111,6 +112,8 @@ enum DashConnectPlatformError: LocalizedError, Equatable { return "Could not tell which approved app this login belongs to. Scan the app's QR code again." case .keyRegistrationMismatchedDerivedKey(let purpose): return "The scanned key-registration transition adds a \(purpose.name) key we did not derive." + case .tokenPurchaseWrongIdentity: + return "The scanned token purchase targets a different identity." case .devnetLoginContractNotConfigured: return NSLocalizedString( "The devnet DashConnect contract id is not set or is not a valid identifier. Enter it in Settings β†’ Devnet Settings.", @@ -182,53 +185,88 @@ private struct DashConnectKeyRegistrationDerivedMaterial { let encryptionPublicKey: Data } -protocol DashConnectKeyRegistrationParsing { - func parse(_ transitionBytes: Data) throws -> DashConnectKeyRegistrationTransition +/// A token direct purchase parsed out of a `dash-st:` transition: what the +/// approval sheet must show, and what `tokenPurchase(...)` is rebuilt from +/// after the user approves it. +struct DashConnectTokenPurchaseTransition: Equatable { + /// The identity whose credits pay for the purchase. + let ownerId: Data + let dataContractId: Data + let tokenId: Data + let tokenContractPosition: UInt16 + let tokenCount: UInt64 + /// Credits the dApp asks the owner to agree to pay in total. + let totalAgreedPrice: UInt64 } -struct PlatformWalletDashConnectKeyRegistrationParser: DashConnectKeyRegistrationParsing { - private let parseTransition: (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition +/// One `dash-st:` payload reduced to app-level types, discriminated by the +/// kind of state transition it carried. +enum DashConnectParsedStateTransition: Equatable { + case keyRegistration(DashConnectKeyRegistrationTransition) + case tokenPurchase(DashConnectTokenPurchaseTransition) +} + +protocol DashConnectStateTransitionParsing { + func parse(_ transitionBytes: Data) throws -> DashConnectParsedStateTransition +} + +struct PlatformWalletDashConnectStateTransitionParser: DashConnectStateTransitionParsing { + private let parseTransition: (Data) throws -> ManagedPlatformWallet.ParsedStateTransition init( - parseTransition: @escaping (Data) throws -> ManagedPlatformWallet.ParsedIdentityUpdateTransition = { bytes in + parseTransition: @escaping (Data) throws -> ManagedPlatformWallet.ParsedStateTransition = { bytes in try MainActor.assumeIsolated { guard let wallet = SwiftDashSDKHost.shared.wallet else { throw DashConnectPlatformError.noWallet } - return try wallet.parseIdentityUpdateTransition(bytes) + return try wallet.parseStateTransition(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 - ) - } - } + func parse(_ transitionBytes: Data) throws -> DashConnectParsedStateTransition { + switch try parseTransition(transitionBytes) { + case .identityUpdate(let parsed): + return .keyRegistration( + 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 ) - }, - disablePublicKeyIds: parsed.disablePublicKeyIds - ) + ) + case .tokenPurchase(let parsed): + return .tokenPurchase( + DashConnectTokenPurchaseTransition( + ownerId: parsed.ownerId, + dataContractId: parsed.dataContractId, + tokenId: parsed.tokenId, + tokenContractPosition: parsed.tokenContractPosition, + tokenCount: parsed.tokenCount, + totalAgreedPrice: parsed.totalAgreedPrice + ) + ) + } } } @@ -270,7 +308,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { private let store: any DashConnectStore private let subject: CurrentValueSubject<[DAppConnection], Never> private let authorizer: DWIdentityAuthorizer - private let keyRegistrationParser: any DashConnectKeyRegistrationParsing + private let stateTransitionParser: any DashConnectStateTransitionParsing private let now: () -> Date /// The DashConnect network matching the app's current network selection β€” @@ -289,7 +327,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { supportedNetwork: DashConnectNetwork = PlatformDashConnectDataSource.currentEnvironmentNetwork(), store: (any DashConnectStore)? = nil, authorizer: DWIdentityAuthorizer = DWIdentityAuthorizer(), - keyRegistrationParser: any DashConnectKeyRegistrationParsing = PlatformWalletDashConnectKeyRegistrationParser(), + stateTransitionParser: any DashConnectStateTransitionParsing = PlatformWalletDashConnectStateTransitionParser(), now: @escaping () -> Date = Date.init ) { assert( @@ -298,7 +336,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { self.supportedNetwork = supportedNetwork self.store = store ?? UserDefaultsDashConnectStore(network: supportedNetwork) self.authorizer = authorizer - self.keyRegistrationParser = keyRegistrationParser + self.stateTransitionParser = stateTransitionParser self.now = now self.subject = CurrentValueSubject(self.store.load()) } @@ -319,7 +357,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { if DashConnectUri.isStUri(trimmed) { let request = try DashConnectUri.parseStRequest(trimmed) try validateNetwork(request.network) - return .keyRegistration(request) + return .stateTransition(request) } throw DashConnectMockError.notDashConnectQrCode @@ -456,21 +494,40 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { return connection } - func completeKeyRegistration(_ request: DashStRequest) async throws { + func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction { try validateNetwork(request.network) let context = try await requireContext() - // 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. - // - // Parsed before the connection is chosen: `DashStRequest` carries no app - // identifier, but the transition's keys usually do, in their contract - // bounds. Picking the most recently approved connection instead would - // derive app B's keys for a QR scanned from app A. - let transition = try await MainActor.run { - try keyRegistrationParser.parse(request.transitionBytes) + // The incoming bytes are parsed only to learn the intent β€” they are + // never signed. Each branch validates the parsed values and rebuilds + // the operation through the SDK itself. + let parsed = try await MainActor.run { + try stateTransitionParser.parse(request.transitionBytes) + } + + switch parsed { + case .keyRegistration(let transition): + try await completeKeyRegistration(transition, context: context) + return .keyRegistrationCompleted + case .tokenPurchase(let purchase): + return .tokenPurchaseApprovalRequired( + try makeTokenPurchaseRequest(purchase, context: context) + ) } + } + /// The key-registration half of a scanned `dash-st:` payload. Verifies + /// the parsed transition only adds the exact derived login keys for our + /// identity, then rebuilds the equivalent `updateIdentity(...)` call + /// through the SDK. + private func completeKeyRegistration( + _ transition: DashConnectKeyRegistrationTransition, + context: Context + ) async throws { + // The transition was parsed before the connection is chosen: + // `DashStRequest` carries no app identifier, but the transition's + // keys usually do, in their contract bounds. Picking the most + // recently approved connection instead would derive app B's keys for + // a QR scanned from app A. let pendingConnection = try pendingApprovedConnectionForKeyRegistration( boundContractId: Self.boundAppContractId(in: transition) ) @@ -564,6 +621,64 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { ) } + /// Builds the user-facing approval request for a parsed token purchase, + /// refusing purchases that name someone else's identity. Local data only: + /// resolving richer metadata (token name, DPNS username) would add + /// network calls the purchase itself does not need. + private func makeTokenPurchaseRequest( + _ purchase: DashConnectTokenPurchaseTransition, + context: Context + ) throws -> DashConnectTokenPurchaseRequest { + // Checked before anything is shown: a purchase that would charge a + // different identity must be refused, not rendered for approval. + guard purchase.ownerId == context.identityId else { + throw DashConnectPlatformError.tokenPurchaseWrongIdentity + } + + let contractIdBase58 = purchase.dataContractId.toBase58String() + return DashConnectTokenPurchaseRequest( + // A connection approved earlier for the same contract names the + // app; otherwise the sheet falls back to the contract id. + appName: subject.value.first { $0.id == contractIdBase58 }?.name, + ownerId: purchase.ownerId, + dataContractId: purchase.dataContractId, + tokenId: purchase.tokenId, + tokenContractPosition: purchase.tokenContractPosition, + tokenCount: purchase.tokenCount, + totalAgreedPriceCredits: purchase.totalAgreedPrice, + walletUsername: context.storedUsername, + walletIdentityId: context.identityId.toBase58String() + ) + } + + func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws { + let context = try await requireContext() + // Re-checked at approve time: the sheet can sit open while the + // wallet's identity changes, and the purchase must only ever debit + // the identity the user saw on the sheet. + guard request.ownerId == context.identityId else { + throw DashConnectPlatformError.tokenPurchaseWrongIdentity + } + + try await authorize() + + let signer = KeychainSigner(modelContainer: context.modelContainer) + // `expectedTotalCost` is the same credits figure the approval sheet + // rendered β€” Platform rejects the transition if the on-chain price + // disagrees, so the charged amount cannot diverge from the shown one. + // `signingKeyId` is left at its default: the signer selects a + // CRITICAL key itself and fails with a clear error when the identity + // has none. + try await context.wallet.tokenPurchase( + identityId: context.identityId, + contractId: request.dataContractId, + tokenPosition: request.tokenContractPosition, + amount: request.tokenCount, + expectedTotalCost: request.totalAgreedPriceCredits, + signer: signer + ) + } + func remove(id: String) async { persistAndSend(subject.value.filter { $0.id != id }) } diff --git a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift index 3fd67f0bb..6472c0b50 100644 --- a/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift +++ b/DashWallet/Sources/UI/DashConnect/ApproveConnectionSheet.swift @@ -20,6 +20,16 @@ import DashUIKit import SwiftUI +/// Truncates long identifiers in the middle for display, keeping `prefix` +/// leading and `suffix` trailing characters (e.g. "5DbLwAx…FzUo8"). Shared +/// by the DashConnect approval sheets. +enum DashConnectIdentifierFormatting { + static 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))" + } +} + struct ApproveConnectionSheet: View { let request: ConnectionRequest var isLoading: Bool = false @@ -69,15 +79,15 @@ struct ApproveConnectionSheet: View { if request.walletUsername != nil || request.walletIdentityId != nil { VStack(spacing: 4) { if let walletUsername = request.walletUsername { - DetailRow( + DashConnectDetailRow( label: NSLocalizedString("Username", comment: "DashConnect"), value: walletUsername ) } if let walletIdentityId = request.walletIdentityId { - DetailRow( + DashConnectDetailRow( label: NSLocalizedString("Identity", comment: "DashConnect"), - value: truncateMiddle(walletIdentityId) + value: DashConnectIdentifierFormatting.truncateMiddle(walletIdentityId) ) } } @@ -225,13 +235,6 @@ struct ApproveConnectionSheet: View { ) } - /// 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 @@ -256,7 +259,9 @@ private struct PermissionRow: View { } } -private struct DetailRow: View { +/// Label/value row used inside the bordered detail boxes of the DashConnect +/// approval sheets. +struct DashConnectDetailRow: View { let label: String let value: String @@ -278,13 +283,10 @@ private struct DetailRow: View { #if DEBUG private let dashConnectTruncateMiddleCheck: Bool = { - let sheet = ApproveConnectionSheet( - request: MockDashConnectDataSource.sampleRequest, - onApprove: {}, - onDeny: {} - ) assert( - sheet.truncateMiddle("5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8") == "5DbLwAx…zUo8" + DashConnectIdentifierFormatting.truncateMiddle( + "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8" + ) == "5DbLwAx…FzUo8" ) return true }() diff --git a/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift new file mode 100644 index 000000000..2406db807 --- /dev/null +++ b/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift @@ -0,0 +1,206 @@ +// +// ApproveTokenPurchaseSheet.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 + +/// Asks the user to approve a token purchase a dApp handed the wallet via a +/// `dash-st:` payload. Everything shown here comes from the wallet's own +/// parse of that payload; on approve the purchase is rebuilt and signed by +/// the wallet β€” the incoming bytes themselves are never signed. +struct ApproveTokenPurchaseSheet: View { + let request: DashConnectTokenPurchaseRequest + 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 + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color.gray300Alpha50) + .frame(width: 36, height: 5) + .padding(.top, 6) + .padding(.bottom, 20) + + // The detail rows grow with Dynamic Type; without a scroll + // container the actions can be pushed below the bottom of the + // sheet, leaving the user unable to approve or deny. The actions + // stay pinned under the scroll area. + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text(NSLocalizedString("Approve token purchase?", comment: "DashConnect token purchase")) + .font(.title2) + .foregroundColor(.primaryText) + + Text(resolvedSubtitle) + .font(.subhead) + .foregroundColor(.secondaryText) + } + + detailBox { + DashConnectDetailRow( + label: NSLocalizedString("Tokens", comment: "DashConnect token purchase"), + value: tokenCountText + ) + DashConnectDetailRow( + label: NSLocalizedString("Token ID", comment: "DashConnect token purchase"), + value: DashConnectIdentifierFormatting.truncateMiddle(request.tokenId.toBase58String()) + ) + DashConnectDetailRow( + label: NSLocalizedString("Total price", comment: "DashConnect token purchase"), + value: request.totalPriceDash.formattedDashAmount + ) + } + + detailBox { + if let walletUsername = request.walletUsername { + DashConnectDetailRow( + label: NSLocalizedString("Username", comment: "DashConnect"), + value: walletUsername + ) + } + DashConnectDetailRow( + label: NSLocalizedString("Identity", comment: "DashConnect"), + value: DashConnectIdentifierFormatting.truncateMiddle(request.walletIdentityId) + ) + } + + Text(NSLocalizedString( + "Approving pays the total price from this identity's Platform credits.", + comment: "DashConnect token purchase" + )) + .font(.footnote) + .foregroundColor(.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + + 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, + 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 + ) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + } + .background(Color.primaryBackground) + } + + /// The requesting app's stored name when a connection for the purchase's + /// contract exists locally; the contract id otherwise, so the user always + /// sees which contract the purchase belongs to. + private var resolvedSubtitle: String { + if let appName = request.appName?.trimmingCharacters(in: .whitespacesAndNewlines), !appName.isEmpty { + return appName + } + return String( + format: NSLocalizedString("Contract %@", comment: "DashConnect token purchase"), + DashConnectIdentifierFormatting.truncateMiddle(request.dataContractId.toBase58String()) + ) + } + + private var tokenCountText: String { + Decimal(request.tokenCount).string + } + + private func detailBox(@ViewBuilder content: () -> some View) -> some View { + VStack(spacing: 4) { + content() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray300, lineWidth: 1.5) + ) + } +} + +// MARK: - Previews + +/// Ungated like `MockDashConnectDataSource`'s samples: `#Preview` bodies +/// compile in every configuration, so preview fixtures cannot be DEBUG-only. +private let sampleTokenPurchaseRequest = DashConnectTokenPurchaseRequest( + appName: "Yappr", + ownerId: Data(repeating: 0x11, count: 32), + dataContractId: Data(repeating: 0xcd, count: 32), + tokenId: Data(repeating: 0xab, count: 32), + tokenContractPosition: 0, + tokenCount: 100, + totalAgreedPriceCredits: 50_000_000_000, + walletUsername: "dashuser", + walletIdentityId: "5DbLwAxEWR695MsqP4KybNQD5n7CUDWydJYNg63FzUo8" +) + +#Preview("Token Purchase") { + ApproveTokenPurchaseSheet( + request: sampleTokenPurchaseRequest, + onApprove: {}, + onDeny: {} + ) +} + +#Preview("Token Purchase Loading") { + ApproveTokenPurchaseSheet( + request: sampleTokenPurchaseRequest, + isLoading: true, + onApprove: {}, + onDeny: {} + ) +} + +#Preview("Token Purchase Error") { + ApproveTokenPurchaseSheet( + request: sampleTokenPurchaseRequest, + errorText: "Could not complete the DashConnect request: no CRITICAL authentication key.", + onApprove: {}, + onDeny: {} + ) +} diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift index b3212803d..3ac607427 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift @@ -51,7 +51,7 @@ struct ConnectionsScreen: View { content } - if viewModel.isProcessingKeyRegistration { + if viewModel.isProcessingStateTransition { Color.black.opacity(0.08) .ignoresSafeArea() @@ -80,6 +80,11 @@ struct ConnectionsScreen: View { approveSheet(for: request) } } + .sheet(isPresented: isTokenPurchaseSheetPresented) { + if let purchase = viewModel.pendingTokenPurchase { + tokenPurchaseSheet(for: purchase) + } + } } // MARK: - States @@ -111,6 +116,17 @@ struct ConnectionsScreen: View { .approveSheetPresentation(isLoading: viewModel.isApproving) } + private func tokenPurchaseSheet(for purchase: DashConnectTokenPurchaseRequest) -> some View { + ApproveTokenPurchaseSheet( + request: purchase, + isLoading: viewModel.isApprovingPurchase, + errorText: viewModel.purchaseApproveError, + onApprove: { viewModel.approvePendingTokenPurchase() }, + onDeny: { viewModel.denyPendingTokenPurchase() } + ) + .approveSheetPresentation(isLoading: viewModel.isApprovingPurchase) + } + // MARK: - Presentation bindings private var isApproveSheetPresented: Binding { @@ -124,6 +140,17 @@ struct ConnectionsScreen: View { ) } + private var isTokenPurchaseSheetPresented: Binding { + Binding( + get: { viewModel.pendingTokenPurchase != nil }, + set: { isPresented in + if !isPresented && !viewModel.isApprovingPurchase { + viewModel.denyPendingTokenPurchase() + } + } + ) + } + private var messageBinding: Binding { Binding( get: { viewModel.message }, diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift index 0b420491b..2655eb026 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -48,13 +48,18 @@ final class ConnectionsViewModel: ObservableObject { @Published private(set) var connections: [DAppConnection] = [] @Published private(set) var featureUnavailable: Bool @Published var pendingRequest: ConnectionRequest? + @Published var pendingTokenPurchase: DashConnectTokenPurchaseRequest? @Published var isApproving = false - @Published var isProcessingKeyRegistration = false + @Published var isApprovingPurchase = false + @Published var isProcessingStateTransition = false @Published var message: ConnectionsScreenMessage? /// 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? + /// Failure of the last token-purchase approve attempt, rendered inside + /// the purchase sheet for the same reason as `approveError`. + @Published var purchaseApproveError: String? private let dataSource: any DashConnectDataSource private var pendingLoginRequest: DashKeyRequest? @@ -83,20 +88,25 @@ final class ConnectionsViewModel: ObservableObject { do { pendingRequest = nil pendingLoginRequest = nil + pendingTokenPurchase = 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") - ) + case let .stateTransition(request): + isProcessingStateTransition = true + defer { isProcessingStateTransition = false } + + switch try await dataSource.handleStateTransition(request) { + case .keyRegistrationCompleted: + message = ConnectionsScreenMessage( + kind: .success, + text: NSLocalizedString("DashConnect key registration completed.", comment: "DashConnect") + ) + case let .tokenPurchaseApprovalRequired(purchase): + pendingTokenPurchase = purchase + } } } catch { message = ConnectionsScreenMessage( @@ -147,6 +157,41 @@ final class ConnectionsViewModel: ObservableObject { approveError = nil } + func approvePendingTokenPurchase() { + guard let purchase = pendingTokenPurchase, !isApprovingPurchase else { return } + + isApprovingPurchase = true + purchaseApproveError = nil + + Task { + defer { isApprovingPurchase = false } + + do { + try await dataSource.approveTokenPurchase(purchase) + self.pendingTokenPurchase = nil + self.purchaseApproveError = nil + message = ConnectionsScreenMessage( + kind: .success, + text: NSLocalizedString("Token purchase completed.", comment: "DashConnect") + ) + } catch { + // Keep the sheet up so the user can retry without rescanning + // the QR. Failures β€” including "the identity has no CRITICAL + // key" from the SDK β€” surface here as the sheet's error text. + self.purchaseApproveError = String( + format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), + error.localizedDescription + ) + } + } + } + + func denyPendingTokenPurchase() { + guard !isApprovingPurchase else { return } + pendingTokenPurchase = nil + purchaseApproveError = nil + } + func disconnect(_ connection: DAppConnection) { Task { await dataSource.disconnect(id: connection.id) diff --git a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift index f51113f78..25fee3c44 100644 --- a/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/DashConnectDataSourceTests.swift @@ -20,13 +20,13 @@ final class DashConnectDataSourceTests: XCTestCase { XCTAssertEqual(request.network, .testnet) } - func testParseQRRoutesValidDashStUriToKeyRegistration() async throws { + func testParseQRRoutesValidDashStUriToStateTransition() 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") + guard case let .stateTransition(request) = result else { + return XCTFail("Expected a state transition request") } XCTAssertEqual(request.transitionBytes, Data([0x01, 0x02, 0x03, 0x04])) @@ -78,16 +78,16 @@ final class DashConnectDataSourceTests: XCTestCase { XCTAssertEqual(request.contractId, contractId) } - func testCompleteKeyRegistrationThrowsOnMock() async throws { + func testHandleStateTransitionThrowsOnMock() async throws { let dataSource = MockDashConnectDataSource() await XCTAssertThrowsErrorAsync({ - try await dataSource.completeKeyRegistration(.init( + try await dataSource.handleStateTransition(.init( transitionBytes: Data([0x01, 0x02, 0x03, 0x04]), network: .testnet )) }) { error in - XCTAssertEqual(error as? DashConnectMockError, .keyRegistrationNotSupported) + XCTAssertEqual(error as? DashConnectMockError, .stateTransitionNotSupported) } } diff --git a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift index 293030506..96e82d506 100644 --- a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -187,10 +187,12 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { XCTAssertEqual(tagless.addPublicKeys.map(\.keyId), tagged.addPublicKeys.map(\.keyId)) XCTAssertEqual(tagless.disablePublicKeyIds, tagged.disablePublicKeyIds) - let appParser = PlatformWalletDashConnectKeyRegistrationParser { bytes in - try wallet.parseIdentityUpdateTransition(bytes) + let appParser = PlatformWalletDashConnectStateTransitionParser { bytes in + .identityUpdate(try wallet.parseIdentityUpdateTransition(bytes)) + } + guard case let .keyRegistration(appTransition) = try appParser.parse(taglessBytes) else { + return XCTFail("Expected a key-registration transition") } - let appTransition = try appParser.parse(taglessBytes) XCTAssertEqual(appTransition.identityId, tagged.identityId) XCTAssertEqual(appTransition.addPublicKeys.map(\.keyId), [17, 18]) XCTAssertEqual(appTransition.disablePublicKeyIds, [4, 8]) From 43bfabdc8057f024afdff413d093d474e53d4119 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:37:43 +0300 Subject: [PATCH 3/4] fix(dashpay): mark each step of a DashConnect login approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `approveLogin` logged that it started and then went silent until it finished, so a stall anywhere across key derivation, the Platform document write and the connection preview looked identical from a log β€” the whole approval was one opaque span. Adds four markers around the steps that can block, which is what let a report of "the QR never disappears" be narrowed to the wallet having completed its side and the dApp not picking the document up. --- .../Models/DashConnect/PlatformDashConnectDataSource.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 14130d772..0ab94d387 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -408,6 +408,7 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { Self.logger.error("πŸ”— DASHCONNECT :: authorization failed β€” \(error.localizedDescription, privacy: .public)") throw error } + Self.logger.info("πŸ”— DASHCONNECT :: authorized; deriving login key") // `deriveIdentityAuthKeyAtSlot` is main-actor isolated in the SDK. var chainKey = try await MainActor.run { @@ -442,14 +443,20 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { // substrings in the error text instead would break the moment the SDK // rewords or localizes a message, and "duplicate" also matches unique- // index failures that have nothing to do with this document. + // Step markers: everything between `approveLogin started` and the + // finish was silent, so a stall anywhere in derive β†’ write β†’ preview + // was indistinguishable from a stall in any other step. + Self.logger.info("πŸ”— DASHCONNECT :: writing loginKeyResponse document") try await writeLoginKeyResponseDocument( context: context, appContractId: request.contractId, propertiesJSON: propertiesJSON, signer: signer ) + Self.logger.info("πŸ”— DASHCONNECT :: loginKeyResponse document written") let preview = await makeConnectionRequest(from: request) + Self.logger.info("πŸ”— DASHCONNECT :: connection preview resolved") let connection: DAppConnection do { var derivedMaterial = try Self.deriveKeyRegistrationMaterial( From 34758385b7c23b1ace3f77412ec3b4ac13de546f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:21:08 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(dashpay):=20address=20the=20token-purch?= =?UTF-8?q?ase=20review=20=E2=80=94=20no=20blind=20retry,=20verified=20tok?= =?UTF-8?q?en=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the automated review of #1109, plus one merge repair. **A failed purchase is no longer offered for a blind retry.** Every approval builds, signs and submits a NEW direct purchase against the identity's next nonce, and the SDK reports one opaque error for both "the transition was rejected" and "it was submitted and waiting on the outcome failed". The old catch called all of it an incomplete request and left the sheet ready to approve again, so a purchase Platform had already accepted could be bought a second time. `approveTokenPurchase` now throws `DashConnectTokenPurchaseFailure`, which says which side of submission the failure came from: everything up to and including authentication is `beforeSubmission` and keeps today's retry-in-place, while anything out of `tokenPurchase(...)` is `outcomeUnknown` β€” the sheet closes and the user is told to check this identity's tokens and credit balance before buying again. **The token id on the sheet is now the token that gets bought.** The id was copied from the untrusted payload while the purchase is rebuilt from the contract id and position alone, so a crafted payload could display one token and buy another. The request is now refused unless the payload's id matches `calculateTokenId(contractId:position:)` β€” the protocol formula the SDK itself derives from. Also: - `expectedTotalCost` is a ceiling, not an exact price: Platform rejects a higher current price and charges a lower one. Both comments claiming the shown and charged amounts "cannot diverge" said more than that. - The sheet renders the price at full credit precision. Credits are 1e11 per DASH, and the eight-decimal rendering rounded away a sub-duff remainder that is nevertheless charged β€” on a money-authorization surface. - The credits-per-DASH divisor comes from `PlatformCreditsFormatter` instead of a second literal definition. - Tests cover the token-purchase parse branch and the creditsβ†’DASH conversion, including zero, a whole DASH, a whole-duff value and a single credit (the sub-duff case the display used to drop). The merge repair: this branch carried its own copy of the `WalletStartupStatus` cases while the same fix landed on develop as #1108, so the merge left the switch with two of each. Kept develop's. Built against platform `v4.2-dev` (which now carries dashpay/platform#4584); succeeds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RstH83XqqRc6oudvpu7UxZ --- .../DashPayContactAddressReadiness.swift | 23 ------ .../DashConnect/DashConnectDataSource.swift | 36 ++++++++- .../DashConnect/DashConnectModels.swift | 23 ++++-- .../PlatformDashConnectDataSource.swift | 80 ++++++++++++++----- .../ApproveTokenPurchaseSheet.swift | 2 +- .../UI/DashConnect/ConnectionsViewModel.swift | 25 +++++- DashWallet/en.lproj/Localizable.strings | 27 +++++++ .../PlatformDashConnectDataSourceTests.swift | 55 +++++++++++++ 8 files changed, 217 insertions(+), 54 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift index 1044794dc..a5fe505ec 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift @@ -227,29 +227,6 @@ enum DashPayContactAddressReadiness { \(seconds, privacy: .public)s; starting SPV without assuming \ DashPay readiness """) - case .seedBindingUnverified: - // The signer handed to the call belongs to a different wallet, so - // the drain derived nothing rather than write contact addresses - // from the wrong seed. A rerun with the right signer completes the - // work, which stays queued β€” hence warning, not error. - logger.warning( - """ - πŸ‘₯ DP-READY :: contact crypto could not verify this wallet's seed \ - after \(seconds, privacy: .public)s; starting SPV, contact accounts \ - stay queued for a run with the matching signer - """) - case .identityScanIncomplete: - // Every later step ran for the identity that is known, but the - // gap-limit scan left indices unanswered, so the identity set is - // not established. The verdict stays on record and the next launch - // re-scans instead of taking the warm shortcut. - logger.warning( - """ - πŸ‘₯ DP-READY :: identity scan left indices unanswered after \ - \(seconds, privacy: .public)s \ - scans=\(outcome.discoveryAttempts, privacy: .public); \ - starting SPV, the next start re-scans - """) } } } diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift index 22b6b804d..41ea7e552 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -31,11 +31,44 @@ protocol DashConnectDataSource { /// explicit user approval via `approveTokenPurchase(_:)`. func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction /// Rebuilds, signs and submits a token purchase the user approved. + /// + /// Throws `DashConnectTokenPurchaseFailure`, which says whether the + /// transition could already have reached Platform β€” the caller must not + /// offer a retry when it could have. func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws func disconnect(id: String) async func remove(id: String) async } +/// Why a token purchase failed, and β€” the part that decides what the UI may +/// offer next β€” whether the transition could already have reached Platform. +/// +/// A purchase is not idempotent: each approval builds and signs a new direct +/// purchase against the identity's next nonce. Retrying after a failure that +/// only looked like a failure buys the tokens a second time and debits the +/// credits a second time, so "did this reach Platform?" has to survive as far +/// as the screen. +enum DashConnectTokenPurchaseFailure: LocalizedError { + /// Refused before anything was signed or submitted β€” a wrong identity, a + /// mismatched token id, a cancelled authentication, a missing runtime. + /// Nothing was charged and approving again is safe. + case beforeSubmission(Error) + /// The transition was signed and handed to Platform, and the failure came + /// out of that call. Platform may have accepted it anyway (a finality + /// timeout, a dropped DAPI response), so the purchase must be treated as + /// possibly complete. + case outcomeUnknown(Error) + + var underlying: Error { + switch self { + case .beforeSubmission(let error), .outcomeUnknown(let error): + return error + } + } + + var errorDescription: String? { underlying.localizedDescription } +} + enum DashConnectMockError: LocalizedError, Equatable { case parseFailed case approveFailed @@ -202,7 +235,8 @@ final class MockDashConnectDataSource: DashConnectDataSource { } func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws { - throw DashConnectMockError.stateTransitionNotSupported + throw DashConnectTokenPurchaseFailure.beforeSubmission( + DashConnectMockError.stateTransitionNotSupported) } func disconnect(id: String) async { diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift index 022a60e2d..ae6ff71ce 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -53,20 +53,29 @@ struct DashConnectTokenPurchaseRequest: Equatable { let tokenId: Data let tokenContractPosition: UInt16 let tokenCount: UInt64 - /// Total price in Platform credits; passed to `tokenPurchase(...)` as - /// `expectedTotalCost` so the shown and charged amounts cannot diverge. + /// Total price in Platform credits, as the payload asked for it. Passed + /// to `tokenPurchase(...)` as `expectedTotalCost`, which is the MAXIMUM + /// the user approves: Platform rejects the transition if the current + /// price is higher, and charges the lower amount if it is lower. let totalAgreedPriceCredits: UInt64 let walletUsername: String? let walletIdentityId: String } extension DashConnectTokenPurchaseRequest { - /// Platform credits per DASH (1e11 β€” 1e8 duffs x 1000 credits per duff). - static let creditsPerDash: Decimal = 100_000_000_000 - - /// The total price converted to DASH for display. + /// The total price converted to DASH for display, over the one + /// credits-per-DASH definition this module already has. A second copy of + /// the divisor is how a money display drifts from the money. var totalPriceDash: Decimal { - Decimal(totalAgreedPriceCredits) / Self.creditsPerDash + Decimal(totalAgreedPriceCredits) / Decimal(PlatformCreditsFormatter.creditsPerDash) + } + + /// The total price rendered for the approval sheet, at the full credit + /// precision. Credits are 1e11 per DASH, so an eight-digit rendering + /// silently rounds away a sub-duff remainder that is nevertheless + /// charged β€” not something a money-authorization surface should hide. + var totalPriceDashText: String { + PlatformCreditsFormatter.dashString(totalAgreedPriceCredits) } } diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 0ab94d387..0bb4a791d 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -69,6 +69,7 @@ enum DashConnectPlatformError: LocalizedError, Equatable { case keyRegistrationUnexpectedMutation case keyRegistrationMismatchedDerivedKey(KeyPurpose) case tokenPurchaseWrongIdentity + case tokenPurchaseTokenIdMismatch case ephemeralKeyGenerationFailed case ambiguousKeyRegistrationConnection case devnetLoginContractNotConfigured @@ -114,6 +115,8 @@ enum DashConnectPlatformError: LocalizedError, Equatable { return "The scanned key-registration transition adds a \(purpose.name) key we did not derive." case .tokenPurchaseWrongIdentity: return "The scanned token purchase targets a different identity." + case .tokenPurchaseTokenIdMismatch: + return "The scanned token purchase names a token that does not belong to the contract and position it would buy from." case .devnetLoginContractNotConfigured: return NSLocalizedString( "The devnet DashConnect contract id is not set or is not a valid identifier. Enter it in Settings β†’ Devnet Settings.", @@ -643,6 +646,23 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { } let contractIdBase58 = purchase.dataContractId.toBase58String() + + // The sheet shows the token id the payload claims, but the purchase + // is rebuilt from the contract id and position alone β€” the SDK + // derives the real token id from those and never sees the claimed + // one. Without this check a crafted payload could display one token + // while buying another. `calculateTokenId` is the protocol formula + // (double_sha256("dash_token" || contract_id || u16_be(position))), + // so what is displayed is what will be bought. + let derivedTokenId = try context.sdk.calculateTokenId( + contractId: contractIdBase58, + position: purchase.tokenContractPosition) + guard derivedTokenId == purchase.tokenId.toBase58String() else { + Self.logger.error( + "πŸ”— DASHCONNECT :: token purchase names a token id the contract/position does not derive") + throw DashConnectPlatformError.tokenPurchaseTokenIdMismatch + } + return DashConnectTokenPurchaseRequest( // A connection approved earlier for the same contract names the // app; otherwise the sheet falls back to the contract id. @@ -659,31 +679,53 @@ final class PlatformDashConnectDataSource: DashConnectDataSource { } func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws { - let context = try await requireContext() - // Re-checked at approve time: the sheet can sit open while the - // wallet's identity changes, and the purchase must only ever debit - // the identity the user saw on the sheet. - guard request.ownerId == context.identityId else { - throw DashConnectPlatformError.tokenPurchaseWrongIdentity - } + let context: Context + let signer: KeychainSigner + do { + context = try await requireContext() + // Re-checked at approve time: the sheet can sit open while the + // wallet's identity changes, and the purchase must only ever debit + // the identity the user saw on the sheet. + guard request.ownerId == context.identityId else { + throw DashConnectPlatformError.tokenPurchaseWrongIdentity + } - try await authorize() + try await authorize() + signer = KeychainSigner(modelContainer: context.modelContainer) + } catch { + // Everything above happens before anything is signed or sent, so + // the purchase provably did not start and the caller may offer it + // again. + throw DashConnectTokenPurchaseFailure.beforeSubmission(error) + } - let signer = KeychainSigner(modelContainer: context.modelContainer) // `expectedTotalCost` is the same credits figure the approval sheet - // rendered β€” Platform rejects the transition if the on-chain price - // disagrees, so the charged amount cannot diverge from the shown one. + // rendered, and it is a CEILING: Platform rejects the transition when + // the on-chain price is higher, and charges the lower amount when it + // is lower. So the user can never be charged more than what they + // approved, but they may be charged less. // `signingKeyId` is left at its default: the signer selects a // CRITICAL key itself and fails with a clear error when the identity // has none. - try await context.wallet.tokenPurchase( - identityId: context.identityId, - contractId: request.dataContractId, - tokenPosition: request.tokenContractPosition, - amount: request.tokenCount, - expectedTotalCost: request.totalAgreedPriceCredits, - signer: signer - ) + do { + try await context.wallet.tokenPurchase( + identityId: context.identityId, + contractId: request.dataContractId, + tokenPosition: request.tokenContractPosition, + amount: request.tokenCount, + expectedTotalCost: request.totalAgreedPriceCredits, + signer: signer + ) + } catch { + // One opaque error covers "the transition was rejected" and "it + // was submitted and the wait for its outcome failed", and nothing + // in the FFI result separates them. Report the ambiguity rather + // than guess: a purchase that did land must not be offered for a + // retry that would buy the tokens again on the next nonce. + Self.logger.error( + "πŸ”— DASHCONNECT :: token purchase failed after submission; outcome unknown: \(String(describing: error), privacy: .public)") + throw DashConnectTokenPurchaseFailure.outcomeUnknown(error) + } } func remove(id: String) async { diff --git a/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift b/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift index 2406db807..a7beb4c9c 100644 --- a/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift +++ b/DashWallet/Sources/UI/DashConnect/ApproveTokenPurchaseSheet.swift @@ -69,7 +69,7 @@ struct ApproveTokenPurchaseSheet: View { ) DashConnectDetailRow( label: NSLocalizedString("Total price", comment: "DashConnect token purchase"), - value: request.totalPriceDash.formattedDashAmount + value: request.totalPriceDashText ) } diff --git a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift index bcb3e3446..1edb9a690 100644 --- a/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift +++ b/DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift @@ -234,10 +234,29 @@ final class ConnectionsViewModel: ObservableObject { kind: .success, text: NSLocalizedString("Token purchase completed.", comment: "DashConnect") ) + } catch DashConnectTokenPurchaseFailure.outcomeUnknown(let underlying) { + // The transition was signed and submitted, and only the wait + // for its outcome failed β€” Platform may well have accepted it. + // Approving again would build a second purchase on the next + // nonce and could buy the tokens twice, so the sheet closes + // rather than offering a retry, and the user is told what to + // check before starting over. + self.pendingTokenPurchase = nil + self.purchaseApproveError = nil + message = ConnectionsScreenMessage( + kind: .error, + text: String( + format: NSLocalizedString( + "The purchase was submitted but its result is unknown: %@. Check this identity's tokens and credit balance before buying again β€” approving a second time would pay twice.", + comment: "DashConnect token purchase"), + underlying.localizedDescription + ) + ) } catch { - // Keep the sheet up so the user can retry without rescanning - // the QR. Failures β€” including "the identity has no CRITICAL - // key" from the SDK β€” surface here as the sheet's error text. + // Refused before anything was signed or sent (a cancelled + // authentication, a wrong identity, "the identity has no + // CRITICAL key"): nothing was charged, so keep the sheet up + // and let the user retry without rescanning the QR. self.purchaseApproveError = String( format: NSLocalizedString("Could not complete the DashConnect request: %@", comment: "DashConnect"), error.localizedDescription diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 8a37fe92d..1ee0d8599 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -173,6 +173,12 @@ /* Explore Dash: Filters */ "80 km" = "80 km"; +/* DashConnect token purchase */ +"Approve token purchase?" = "Approve token purchase?"; + +/* DashConnect token purchase */ +"Approving pays the total price from this identity's Platform credits." = "Approving pays the total price from this identity's Platform credits."; + /* DashConnect */ "Available on test networks only" = "Available on test networks only"; @@ -182,9 +188,15 @@ /* Devnet */ "Base58 identifier (optional)" = "Base58 identifier (optional)"; +/* DashConnect token purchase */ +"Contract %@" = "Contract %@"; + /* Devnet */ "DashConnect Contract ID" = "DashConnect Contract ID"; +/* DashConnect */ +"Deny" = "Deny"; + /* Wallet network */ "Devnet" = "Devnet"; @@ -230,6 +242,21 @@ /* Devnet */ "The loginKeyResponse data contract id registered on this devnet. Only needed for DashConnect logins; leave empty otherwise." = "The loginKeyResponse data contract id registered on this devnet. Only needed for DashConnect logins; leave empty otherwise."; +/* DashConnect token purchase */ +"The purchase was submitted but its result is unknown: %@. Check this identity's tokens and credit balance before buying again β€” approving a second time would pay twice." = "The purchase was submitted but its result is unknown: %@. Check this identity's tokens and credit balance before buying again β€” approving a second time would pay twice."; + +/* DashConnect token purchase */ +"Token ID" = "Token ID"; + +/* DashConnect */ +"Token purchase completed." = "Token purchase completed."; + +/* DashConnect token purchase */ +"Tokens" = "Tokens"; + +/* DashConnect token purchase */ +"Total price" = "Total price"; + /* No comment provided by engineer. */ "\"%@\" is not a recovery phrase word" = "\"%@\" is not a recovery phrase word"; diff --git a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift index 8a8238b2b..95c786529 100644 --- a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -207,6 +207,61 @@ final class PlatformDashConnectDataSourceTests: XCTestCase { XCTAssertEqual(appTransition.disablePublicKeyIds, [4, 8]) } + func testParserMapsATokenPurchaseTransition() throws { + let ownerId = Data(repeating: 0x21, count: 32) + let contractId = Data(repeating: 0x22, count: 32) + let tokenId = Data(repeating: 0x23, count: 32) + let parser = PlatformWalletDashConnectStateTransitionParser { _ in + .tokenPurchase(ManagedPlatformWallet.ParsedTokenPurchaseTransition( + ownerId: ownerId, + dataContractId: contractId, + tokenId: tokenId, + tokenContractPosition: 3, + tokenCount: 100, + totalAgreedPrice: 100_000_000 + )) + } + + guard case let .tokenPurchase(purchase) = try parser.parse(Data([0x00])) else { + return XCTFail("Expected a token purchase") + } + XCTAssertEqual(purchase.ownerId, ownerId) + XCTAssertEqual(purchase.dataContractId, contractId) + XCTAssertEqual(purchase.tokenId, tokenId) + XCTAssertEqual(purchase.tokenContractPosition, 3) + XCTAssertEqual(purchase.tokenCount, 100) + XCTAssertEqual(purchase.totalAgreedPrice, 100_000_000) + } + + func testTokenPurchasePriceConvertsCreditsToDash() { + // 1e11 credits = 1 DASH; 1e3 credits = 1 duff. + XCTAssertEqual(Self.purchaseRequest(credits: 0).totalPriceDash, 0) + XCTAssertEqual(Self.purchaseRequest(credits: 100_000_000_000).totalPriceDash, 1) + XCTAssertEqual( + Self.purchaseRequest(credits: 100_000).totalPriceDash, + Decimal(string: "0.000001")) + // Sub-duff precision survives: 1 credit is a thousandth of a duff, + // which an eight-decimal rendering would round away even though it + // is charged. + XCTAssertEqual( + Self.purchaseRequest(credits: 1).totalPriceDash, + Decimal(string: "0.00000000001")) + } + + private static func purchaseRequest(credits: UInt64) -> DashConnectTokenPurchaseRequest { + DashConnectTokenPurchaseRequest( + appName: nil, + ownerId: Data(repeating: 0x21, count: 32), + dataContractId: Data(repeating: 0x22, count: 32), + tokenId: Data(repeating: 0x23, count: 32), + tokenContractPosition: 0, + tokenCount: 1, + totalAgreedPriceCredits: credits, + walletUsername: nil, + walletIdentityId: "identity" + ) + } + func testBuildLoginKeyResponseDraftProducesExactFieldsAndWipesEphemeralPrivateKey() throws { let loginKey = Data(repeating: 0x11, count: 32) let appContractId = Data(repeating: 0xcd, count: 32)