diff --git a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift index b628d6f95..41ea7e552 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift @@ -26,17 +26,55 @@ 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. + /// + /// 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 case notDashConnectQrCode case unsupportedNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork) - case keyRegistrationNotSupported + case stateTransitionNotSupported var errorDescription: String? { switch self { @@ -48,8 +86,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 +188,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 +229,14 @@ 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 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 b42100950..ae6ff71ce 100644 --- a/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift +++ b/DashWallet/Sources/Models/DashConnect/DashConnectModels.swift @@ -22,7 +22,61 @@ 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, 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 { + /// 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) / 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) + } } /// Lifecycle of an app connection. diff --git a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift index 5c8361656..0bb4a791d 100644 --- a/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift +++ b/DashWallet/Sources/Models/DashConnect/PlatformDashConnectDataSource.swift @@ -68,6 +68,8 @@ enum DashConnectPlatformError: LocalizedError, Equatable { case keyRegistrationWrongIdentity case keyRegistrationUnexpectedMutation case keyRegistrationMismatchedDerivedKey(KeyPurpose) + case tokenPurchaseWrongIdentity + case tokenPurchaseTokenIdMismatch case ephemeralKeyGenerationFailed case ambiguousKeyRegistrationConnection case devnetLoginContractNotConfigured @@ -111,6 +113,10 @@ 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 .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.", @@ -182,53 +188,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 +311,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 +330,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 +339,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 +360,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 @@ -370,6 +411,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 { @@ -404,14 +446,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( @@ -456,21 +504,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 +631,103 @@ 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() + + // 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. + 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: 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() + 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) + } + + // `expectedTotalCost` is the same credits figure the approval sheet + // 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. + 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 { 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..a7beb4c9c --- /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.totalPriceDashText + ) + } + + 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 a988d7cf7..1edb9a690 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? @@ -98,22 +103,23 @@ final class ConnectionsViewModel: ObservableObject { guard !featureUnavailable else { return } // A request the user is already looking at owns the screen until they - // answer it. `pendingRequest` covers both halves of that: the approve - // sheet is presented exactly while it is set, and an approval in - // flight keeps it set. Refusing the newcomer beats replacing a request - // mid-read — and beats what replacing used to cost, since resolving - // the newcomer starts by clearing the sheet, so an unparseable link - // could dismiss a legitimate approval on its way to failing. + // answer it. Both sheets count: `pendingRequest` presents the connection + // approval (and stays set while an approval is in flight), and + // `pendingTokenPurchase` presents a purchase waiting to be authorized. + // Refusing the newcomer beats replacing a request mid-read — and beats + // what replacing used to cost, since resolving the newcomer starts by + // clearing the sheet, so an unparseable link could dismiss a legitimate + // approval on its way to failing. // // The refusal has to appear where the user is looking: the sheet // covers the screen, and a screen-level `.alert` cannot show over it. - guard pendingRequest == nil else { + guard pendingRequest == nil, pendingTokenPurchase == nil else { approveError = NSLocalizedString("Another DashConnect request arrived. Finish this one first, then try again.", comment: "DashConnect: a second request arrived while one was on screen") return } - guard !isProcessingKeyRegistration else { + guard !isProcessingStateTransition else { message = ConnectionsScreenMessage( kind: .error, text: NSLocalizedString("Finish the current DashConnect request first, then try again.", @@ -143,16 +149,20 @@ final class ConnectionsViewModel: ObservableObject { guard generation == requestGeneration else { return } pendingLoginRequest = request pendingRequest = connectionRequest - case let .keyRegistration(request): + case let .stateTransition(request): guard generation == requestGeneration else { return } - isProcessingKeyRegistration = true - defer { isProcessingKeyRegistration = false } + isProcessingStateTransition = true + defer { isProcessingStateTransition = false } - try await dataSource.completeKeyRegistration(request) - message = ConnectionsScreenMessage( - kind: .success, - text: NSLocalizedString("DashConnect key registration completed.", comment: "DashConnect") - ) + 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 { guard generation == requestGeneration else { return } @@ -207,6 +217,60 @@ 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 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 { + // 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 + ) + } + } + } + + func denyPendingTokenPurchase() { + guard !isApprovingPurchase else { return } + pendingTokenPurchase = nil + purchaseApproveError = nil + } + func disconnect(_ connection: DAppConnection) { Task { await dataSource.disconnect(id: connection.id) 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/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 937381052..95c786529 100644 --- a/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift +++ b/DashWalletTests/DashConnect/PlatformDashConnectDataSourceTests.swift @@ -196,15 +196,72 @@ 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]) } + 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)