From 2bf062c0c97fae407437a849d8618eb9b6418b01 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 17:13:02 +0200 Subject: [PATCH 1/8] feat: enable DashPay iOS flow + key health tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rs-sdk: fix DPNS availability false-positive — non-existence proofs come back as an IndexMap with `None` values, not an empty map, so `documents.is_empty()` was reporting available names as taken. Switched to `values().all(is_none)`. - swift-sdk SDK: default `platformVersion` is now network-aware (11 for mainnet/testnet, 12 for devnet/regtest) so we don't emit the V1 `getDocuments` wire format against pre-v3.1 nodes that reject it with "could not decode data contracts query". - swift-sdk PlatformWalletPersistenceHandler.deleteWalletData: switched to a four-phase save (identity-children → identities → accounts → wallet) so SwiftData's save-time inverse cleanup never has to touch a non-optional inverse on a row in the same batch as the parent's delete. Avoids the `Cannot remove PersistentX from relationship Y on PersistentZ` fatal at the cost of atomicity across phases (acceptable for a user-initiated wipe). All model relationships stay non-Optional. - swift-sdk keychain: namespace identity-private-key keychain accounts by walletId (`identity_privkey..`). The old per-path scheme collided whenever two wallets had an identity at the same `identity_index`, leaving prior rows pointing at another wallet's private bytes. - swift-example-app AddIdentityKeyView: surface DashPay as a "(System)" entry in the contract-bounds picker; lock KeyType to ECDSA secp256k1 + SecurityLevel to Medium when purpose is Encryption/Decryption (DPP enforces both); require the document type binding when the contract only declares the bounded-key requirement at document-type level (DashPay → contactRequest), with auto-fill. - swift-example-app CreateIdentityView: new default-on toggle "Register DashPay keys" that registers 2 extra keys (Encryption + Decryption, MEDIUM, ECDSA secp256k1, bound to DashPay → contactRequest) alongside the default auth keys. Asset-lock minimum auto-scales for the extra `identity_key_in_creation_cost`. - swift-example-app WalletKeyHealthSheet: new diagnostic in Wallet Info — re-derives each `PersistentPublicKey` from the wallet mnemonic, classifies healthy / needsRederive / orphan, and offers Re-derive (writes the new-format keychain entry + sweeps the legacy one) or Delete Identity. - swift-example-app WalletRowView.platformBalance + BalanceCardView.platformBalance: skip identities whose `modelContext` is nil to avoid reading invalidated rows during a cascade delete. - Dead code removed: stale `DashPayService` (Swift SDK; hardcoded key indices, didn't go through the new FFI), `ObservableDashPayService` (empty wrapper, never used), `FriendsViewStubs` (folded real types into FriendsView). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 8 +- .../Models/PersistentAccount.swift | 13 + .../PlatformWallet/DashPayService.swift | 262 -------- .../PlatformWalletPersistenceHandler.swift | 57 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 24 +- .../Security/KeychainManager.swift | 26 +- .../Core/Views/CoreContentView.swift | 16 +- .../Core/Views/WalletDetailView.swift | 78 ++- .../Core/Views/WalletKeyHealthSheet.swift | 586 ++++++++++++++++++ .../Views/AddIdentityKeyView.swift | 289 +++++++-- .../Views/CreateIdentityView.swift | 222 ++++++- .../SwiftExampleApp/Views/FriendsView.swift | 59 +- .../Views/FriendsViewStubs.swift | 38 -- .../Views/ObservableDashPayService.swift | 22 - .../Views/StorageRecordDetailViews.swift | 5 +- 15 files changed, 1315 insertions(+), 390 deletions(-) delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index cfb47694cef..c5fa757bb7e 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -405,8 +405,12 @@ impl Sdk { let documents = Document::fetch_many(self, query).await?; - // If no documents found, the name is available - Ok(documents.is_empty()) + // `Document::fetch_many` returns `BTreeMap>` + // — a non-existence proof comes back as a non-empty map whose values are + // all `None`. Checking `documents.is_empty()` would treat a proven + // non-existence as "taken". The name is available iff no entry in the + // map carries an actual document. + Ok(documents.values().all(|d| d.is_none())) } /// Resolve a DPNS name to an identity ID diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 8fc1666c37a..5e0fe5270f6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -89,6 +89,19 @@ public final class PersistentAccount { /// Parent wallet. Every account currently belongs to a wallet. If /// standalone non-wallet accounts are introduced later, this /// becomes optional again. + /// + /// Kept non-optional. SwiftData would otherwise fatal during + /// the `save()` phase of a wallet delete + /// (`Cannot remove PersistentWallet from relationship wallet on + /// PersistentAccount because an appropriate default value is + /// not configured`); the workaround is in + /// `PlatformWalletPersistenceHandler.deleteWalletData`, which + /// deletes all of the wallet's accounts in a separate + /// `save()` BEFORE deleting the wallet itself. By the time the + /// wallet row is deleted, its `accounts` collection is empty + /// and SwiftData has no inverse to null out. This costs + /// atomicity (two saves instead of one) — acceptable for a + /// user-initiated wipe. public var wallet: PersistentWallet /// Addresses from this account's address pools (external + diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift deleted file mode 100644 index 13a95536036..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayService.swift +++ /dev/null @@ -1,262 +0,0 @@ -import Foundation - -/// Service for managing DashPay contacts and identities -/// -/// This service provides high-level operations for DashPay functionality including: -/// - Identity management -/// - Contact requests (sending, accepting, rejecting) -/// - Established contacts management -/// - Contact metadata (aliases, notes, visibility) -public final class DashPayService: Sendable { - private let platformWallet: SendableBox - private let identityManager: SendableBox - private let currentIdentity: SendableBox - private let network: SendableBox - - public init() { - self.platformWallet = SendableBox(nil) - self.identityManager = SendableBox(nil) - self.currentIdentity = SendableBox(nil) - self.network = SendableBox(.testnet) - } - - // Thread-safe sendable box for reference types - private final class SendableBox: @unchecked Sendable { - private let lock = NSLock() - private var _value: T - - init(_ value: T) { - self._value = value - } - - var value: T { - get { - lock.lock() - defer { lock.unlock() } - return _value - } - set { - lock.lock() - defer { lock.unlock() } - _value = newValue - } - } - } - - // MARK: - Initialization - - /// Initialize Platform Wallet from mnemonic - /// - Parameters: - /// - mnemonic: BIP39 mnemonic phrase - /// - network: Platform network (mainnet, testnet, devnet) - /// - Throws: Error if wallet creation fails - public func initializeWallet(mnemonic: String, network: Network = .testnet) throws { - // Create platform wallet from mnemonic - let wallet = try PlatformWallet.fromMnemonic(mnemonic) - - // Get identity manager for the specified network - let manager = try wallet.getIdentityManager(for: network) - - self.platformWallet.value = wallet - self.identityManager.value = manager - self.network.value = network - } - - // MARK: - Identity Management - - /// Load a managed identity from identity bytes - /// - Parameter identityBytes: Raw identity data - /// - Returns: The loaded managed identity - /// - Throws: Error if identity loading fails - public func loadIdentity(identityBytes: Data) throws -> ManagedIdentity { - let managedIdentity = try ManagedIdentity.fromIdentityBytes(identityBytes) - - // Add to identity manager if available - if let manager = identityManager.value { - try manager.addIdentity(managedIdentity) - } - - self.currentIdentity.value = managedIdentity - return managedIdentity - } - - /// Get all identities from the manager - /// - Returns: Array of identity IDs - /// - Throws: Error if identity manager not initialized - public func getAllIdentities() throws -> [Identifier] { - guard let manager = identityManager.value else { - throw DashPayError.noIdentityManager - } - - return try manager.getAllIdentityIds() - } - - // `setPrimaryIdentity` / `getPrimaryIdentity` were dropped along - // with the underlying Rust field. Callers that previously relied - // on them should track the selection in their own state (UI layer) - // and look up the matching `ManagedIdentity` via - // `IdentityManager.getIdentity(_:)`. - - // MARK: - Contact Requests - - /// Send a contact request to another identity - /// - Parameters: - /// - identity: The identity sending the request - /// - recipientId: The recipient's identity ID - /// - encryptedPublicKey: Encrypted public key for secure communication - /// - coreHeightCreatedAt: Core chain height when the request was created - /// - createdAt: Creation timestamp (ms since epoch) - /// - Throws: Error if sending fails - public func sendContactRequest( - from identity: ManagedIdentity, - to recipientId: Identifier, - encryptedPublicKey: Data, - coreHeightCreatedAt: UInt32 = 0, - createdAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) - ) throws { - // Build the contact request object, then hand it off to the FFI layer. - // Real callers should derive key indices from the identity/recipient. - let senderId = try identity.getId() - let request = try ContactRequest.create( - senderId: senderId, - recipientId: recipientId, - senderKeyIndex: 0, // Should be derived from identity keys - recipientKeyIndex: 0, // Should be looked up from recipient - accountReference: 0, - encryptedPublicKey: encryptedPublicKey, - coreHeightCreatedAt: coreHeightCreatedAt, - createdAt: createdAt - ) - - try identity.sendContactRequest(request) - } - - /// Accept a contact request - /// - Parameters: - /// - identity: The identity accepting the request - /// - senderId: The sender's identity ID - /// - Throws: Error if acceptance fails - public func acceptContactRequest(identity: ManagedIdentity, from senderId: Identifier) throws { - guard let request = try identity.getIncomingContactRequest(senderId: senderId) else { - throw PlatformWalletError.contactNotFound( - "no incoming contact request from sender id" - ) - } - try identity.acceptContactRequest(request) - } - - /// Reject a contact request - /// - Parameters: - /// - identity: The identity rejecting the request - /// - senderId: The sender's identity ID - /// - Throws: Error if rejection fails - public func rejectContactRequest(identity: ManagedIdentity, from senderId: Identifier) throws { - try identity.rejectContactRequest(senderId: senderId) - } - - /// Get all sent contact requests for an identity - /// - Parameter identity: The identity to query - /// - Returns: Array of sent contact requests - /// - Throws: Error if query fails - public func getSentContactRequests(identity: ManagedIdentity) throws -> [ContactRequest] { - let requestIds = try identity.getSentContactRequestIds() - - return try requestIds.compactMap { recipientId in - try identity.getSentContactRequest(recipientId: recipientId) - } - } - - /// Get all incoming contact requests for an identity - /// - Parameter identity: The identity to query - /// - Returns: Array of incoming contact requests - /// - Throws: Error if query fails - public func getIncomingContactRequests(identity: ManagedIdentity) throws -> [ContactRequest] { - let requestIds = try identity.getIncomingContactRequestIds() - - return try requestIds.compactMap { senderId in - try identity.getIncomingContactRequest(senderId: senderId) - } - } - - // MARK: - Established Contacts - - /// Get all established contacts for an identity - /// - Parameter identity: The identity to query - /// - Returns: Array of established contacts - /// - Throws: Error if query fails - public func getEstablishedContacts(identity: ManagedIdentity) throws -> [EstablishedContact] { - let contactIds = try identity.getEstablishedContactIds() - - return try contactIds.compactMap { contactId in - try identity.getEstablishedContact(contactId: contactId) - } - } - - /// Check if a contact is established - /// - Parameters: - /// - identity: The identity to check - /// - contactId: The contact's identity ID - /// - Returns: true if contact is established - /// - Throws: Error if check fails - public func isContactEstablished(identity: ManagedIdentity, contactId: Identifier) throws -> Bool { - return try identity.isContactEstablished(contactId: contactId) - } - - /// Set alias for a contact - /// - Parameters: - /// - contact: The established contact - /// - alias: The alias to set - /// - Throws: Error if operation fails - public func setContactAlias(contact: EstablishedContact, alias: String) throws { - try contact.setAlias(alias) - } - - /// Set note for a contact - /// - Parameters: - /// - contact: The established contact - /// - note: The note to set - /// - Throws: Error if operation fails - public func setContactNote(contact: EstablishedContact, note: String) throws { - try contact.setNote(note) - } - - /// Hide a contact - /// - Parameter contact: The contact to hide - /// - Throws: Error if operation fails - public func hideContact(_ contact: EstablishedContact) throws { - try contact.hide() - } - - /// Unhide a contact - /// - Parameter contact: The contact to unhide - /// - Throws: Error if operation fails - public func unhideContact(_ contact: EstablishedContact) throws { - try contact.unhide() - } -} - -// MARK: - Errors - -/// Errors that can occur in DashPay operations -public enum DashPayError: Error, LocalizedError { - case noWallet - case noIdentityManager - case noCurrentIdentity - case invalidIdentityBytes - case contactNotFound - - public var errorDescription: String? { - switch self { - case .noWallet: - return "Platform wallet not initialized" - case .noIdentityManager: - return "Identity manager not available" - case .noCurrentIdentity: - return "No identity selected" - case .invalidIdentityBytes: - return "Invalid identity data" - case .contactNotFound: - return "Contact not found" - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 20a0b628161..3eef00b5baf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -543,6 +543,7 @@ public class PlatformWalletPersistenceHandler { // single tx can land in multiple accounts (or wallets), and // per-wallet membership is recovered through the TXO graph // (`outputs` / `inputs`) rather than a denormalized column. + // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) let descriptor = FetchDescriptor( @@ -2576,7 +2577,8 @@ public class PlatformWalletPersistenceHandler { let walletNetwork = walletRow?.network if let walletRow = walletRow { - // Wallet identity relationships are `.nullify`; this delete path cascades them explicitly. + // Wallet → identities is `.nullify`; this delete + // path cascades them explicitly. let identitiesToDelete = Array(walletRow.identities) let identityIds = identitiesToDelete.map { $0.identityId } @@ -2589,9 +2591,62 @@ public class PlatformWalletPersistenceHandler { } } + // SwiftData fatals during save() whenever it has + // to null out a non-optional inverse on a child + // being processed in the same save batch (the + // canonical wording is + // `Cannot remove PersistentX from relationship + // Y on PersistentZ because an appropriate + // default value is not configured`). + // Marking children for delete in the SAME batch + // doesn't help — SwiftData still walks their + // inverses during the merge phase. + // + // The workaround is to delete each layer in its + // own `save()`, parent last, so by the time the + // parent's delete runs its relationship + // collections are empty and SwiftData has no + // inverse to clean up. Costs us atomicity (four + // saves) — acceptable for a user-initiated wipe. + // + // PHASE 1: delete every identity's cascade-children + // whose inverse to identity is non-optional + // (DPNS names, DashPay profile, DashPay contact + // requests). PublicKey, Document, and + // TokenBalance inverses to identity are already + // Optional and don't need pre-deletion. + for identity in identitiesToDelete { + for name in Array(identity.dpnsNames) { + backgroundContext.delete(name) + } + if let profile = identity.dashpayProfile { + backgroundContext.delete(profile) + } + for cr in Array(identity.contactRequests) { + backgroundContext.delete(cr) + } + } + try backgroundContext.save() + + // PHASE 2: delete the identities themselves now + // that their problematic cascade children are + // gone from the store. for identity in identitiesToDelete { backgroundContext.delete(identity) } + try backgroundContext.save() + + // PHASE 3: delete the wallet's accounts. Same + // reasoning — `PersistentAccount.wallet` is + // non-optional; deleting accounts in their own + // save() pass leaves the wallet's `accounts` + // collection empty when the wallet itself is + // deleted. + let accountsToDelete = Array(walletRow.accounts) + for account in accountsToDelete { + backgroundContext.delete(account) + } + try backgroundContext.save() } let txoDescriptor = FetchDescriptor( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index f10331e22c8..866c02436e0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -230,6 +230,17 @@ public final class SDK: @unchecked Sendable { /// This uses a trusted context provider that fetches quorum keys and /// data contracts from trusted HTTP endpoints instead of requiring proof verification. /// This is suitable for mobile applications where proof verification would be resource-intensive. + /// + /// `platformVersion`: + /// - `0` (default) — pick a network-appropriate version: PV 11 for the + /// public networks still on pre-v3.1 drive-abci (mainnet, testnet), + /// PV 12 (latest) for the leading-edge networks (devnet, regtest). + /// This avoids the V0/V1 `getDocuments` wire-format mismatch that + /// would otherwise make Platform queries fail with + /// `"decoding error: could not decode data contracts query"` on the + /// public networks until they roll forward. Override by passing an + /// explicit non-zero value. + /// - non-zero — pin the SDK to this exact `PlatformVersion`. public init(network: Network, platformVersion: UInt32 = 0) throws { var config = DashSDKConfig() config.network = network.ffiValue @@ -238,7 +249,18 @@ public final class SDK: @unchecked Sendable { config.skip_asset_lock_proof_verification = false config.request_retry_count = 1 config.request_timeout_ms = 8000 // 8 seconds - config.platform_version = platformVersion // 0 = SDK default (auto-detect) + let resolvedPlatformVersion: UInt32 + if platformVersion != 0 { + resolvedPlatformVersion = platformVersion + } else { + switch network { + case .mainnet, .testnet: + resolvedPlatformVersion = 11 + case .devnet, .regtest: + resolvedPlatformVersion = 12 + } + } + config.platform_version = resolvedPlatformVersion // Create SDK with trusted setup. DAPI / quorum-URL overrides come from // UserDefaults and apply on: diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index de905262d13..c402ac40994 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -615,7 +615,31 @@ extension KeychainManager { derivationPath: String, metadata: IdentityPrivateKeyMetadata ) -> String? { - let account = "identity_privkey.\(derivationPath)" + // Account name MUST be unique per (wallet, derivationPath). + // The earlier scheme `"identity_privkey.\(derivationPath)"` + // collided whenever two wallets had an identity at the same + // `identity_index` — both wallets derive identity keys under + // `m/9'/'/5'/0'/'/'/'`, so the + // path alone isn't unique across wallets. The collision + // caused later writes to overwrite earlier ones in Keychain, + // leaving prior `PersistentPublicKey` rows pointing at an + // account that now holds another wallet's private bytes — + // the signing trampoline would happily return them and + // produce signatures that wouldn't verify. + // + // Including the wallet id (hex) in the account name keeps + // every wallet's identity-key set independent. Reads via + // `retrieveIdentityPrivateKey(publicKeyHex:)` still work + // unmodified — that path scans every `identity_privkey.*` + // item and matches on the metadata's `publicKey` hex, so the + // account-name shape doesn't matter for lookup. The direct + // `retrieveKeyData(identifier:)` path uses whatever string + // we return here, which the persister stores on the + // `PersistentPublicKey.privateKeyKeychainIdentifier` column; + // on the next persister upsert the row gets the new account + // string, while sessions in between fall back to the + // metadata-scan path automatically. + let account = "identity_privkey.\(metadata.walletId).\(derivationPath)" var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 61dcd0bbda9..f2c67fac1b2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -846,12 +846,22 @@ struct WalletRowView: View { /// Platform balance in credits: prefer BLAST address sync, fall /// back to summing identity credits when no addresses have been /// synced yet. + /// + /// Skips identities whose `modelContext` is nil — that's + /// SwiftData's marker for an invalidated row (e.g. mid-wallet- + /// delete, where the relationship array is briefly visible but + /// the underlying rows have already been removed from the + /// store). Reading any persisted property on an invalidated + /// model crashes with `BackingData.swift:866: This model + /// instance was invalidated…`. private var platformBalance: UInt64 { let blastBalance = addressBalances.reduce(UInt64(0)) { $0 + $1.balance } if blastBalance > 0 { return blastBalance } - return identitiesForWallet.reduce(UInt64(0)) { - $0 + UInt64(bitPattern: $1.balance) - } + return identitiesForWallet + .filter { $0.modelContext != nil } + .reduce(UInt64(0)) { + $0 + UInt64(bitPattern: $1.balance) + } } /// One-shot snapshot of the wallet's per-account Core balances. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index f11ee3e723e..5339b4e1a9e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -252,14 +252,25 @@ struct WalletInfoView: View { @State private var isAuthorizingSeedPhrase = false @State private var revealedMnemonic: String? + // "Verify Identity Keys" diagnostic sheet — runs the per-key + // health check + offers re-derive / delete-orphan repair actions. + // See `WalletKeyHealthSheet`. + @State private var showKeyHealthSheet = false + // Account counts come from SwiftData now. @Query private var accounts: [PersistentAccount] + /// Identities owned by this wallet — passed to the key-health + /// sheet so it can iterate them. + @Query private var walletIdentities: [PersistentIdentity] init(wallet: PersistentWallet, onWalletDeleted: @escaping () -> Void = {}) { self.wallet = wallet self.onWalletDeleted = onWalletDeleted let walletId = wallet.walletId _accounts = Query(filter: #Predicate { $0.wallet.walletId == walletId }) + _walletIdentities = Query( + filter: #Predicate { $0.wallet?.walletId == walletId } + ) } var body: some View { @@ -455,6 +466,30 @@ struct WalletInfoView: View { .disabled(isAuthorizingSeedPhrase) } + // Verify Identity Keys Section — diagnostic that + // walks every identity's PersistentPublicKey rows, + // re-derives the canonical key from this wallet's + // mnemonic, and confirms the stored pubkey + the + // Keychain bytes match. Offers re-derive (for + // wallet-owned keys with missing/wrong keychain + // entries) and delete-identity (for orphan rows + // whose pubkey doesn't match the wallet's mnemonic + // at all). The keychain-collision bug between + // wallets at identity_index=0 is the canonical + // trigger for needing this. + Section { + Button { + showKeyHealthSheet = true + } label: { + HStack { + Spacer() + Label("Verify Identity Keys", systemImage: "checkmark.shield") + Spacer() + } + } + .accessibilityIdentifier("walletInfo.verifyIdentityKeysButton") + } + // Delete Wallet Section Section { Button(action: { @@ -519,6 +554,34 @@ struct WalletInfoView: View { SeedPhraseRevealSheet(mnemonic: phrase) } } + .sheet(isPresented: $showKeyHealthSheet) { + if let managed = walletManager.wallet(for: wallet.walletId) { + WalletKeyHealthSheet( + wallet: managed, + walletId: wallet.walletId, + identities: walletIdentities, + network: wallet.network ?? .testnet + ) + } else { + // Wallet manager hasn't loaded this wallet — + // surface a placeholder rather than presenting an + // empty sheet that the user can't interpret. + NavigationView { + ContentUnavailableView( + "Wallet not loaded", + systemImage: "exclamationmark.triangle", + description: Text( + "Open the wallet detail view once before running the key health check." + ) + ) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showKeyHealthSheet = false } + } + } + } + } + } } } @@ -751,6 +814,13 @@ struct BalanceCardView: View { } /// Platform balance from BLAST sync (preferred) or identity sum (fallback). + /// + /// Skips identities whose `modelContext` is nil — SwiftData's + /// marker for an invalidated row. During a wallet delete the + /// relationship array can briefly contain invalidated entries + /// before SwiftUI rerenders past them; reading any persisted + /// property on an invalidated model fatals with + /// `BackingData.swift:866: This model instance was invalidated…`. var platformBalance: UInt64 { let blastBalance = addressBalances.reduce(0) { $0 + $1.balance } let hasSynced = syncStates.first.map { $0.syncHeight > 0 || $0.syncTimestamp > 0 } @@ -762,9 +832,11 @@ struct BalanceCardView: View { // identities (via the SwiftData relationship). Pre-BLAST- // sync state shows approximate credit balance aggregated // from the on-chain identities we know about. - return wallet.identities.reduce(UInt64(0)) { sum, identity in - sum + UInt64(bitPattern: identity.balance) - } + return wallet.identities + .filter { $0.modelContext != nil } + .reduce(UInt64(0)) { sum, identity in + sum + UInt64(bitPattern: identity.balance) + } } var body: some View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift new file mode 100644 index 00000000000..53d4ae5da2b --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -0,0 +1,586 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +// MARK: - Reports + +/// Per-key diagnosis. Status drives the row's icon + the parent +/// identity's roll-up severity. +struct WalletKeyHealth: Identifiable { + let id: Int32 // keyId (stable per identity) + let keyId: UInt32 + let purposeRaw: UInt8 + let securityLevelRaw: UInt8 + let storedPublicKeyHex: String + let derivedPublicKeyHex: String + let status: Status + /// Held so the re-derive action can write the new pkid back. + let row: PersistentPublicKey + + enum Status { + /// Stored pubkey matches the wallet's derivation AND the + /// Keychain entry holds matching private bytes. + case healthy + /// Stored pubkey matches the derivation but the Keychain + /// entry is missing or holds different bytes — a re-derive + /// will repair it. + case needsRederive(reason: String) + /// Stored pubkey doesn't match what this wallet's mnemonic + /// derives at `(identityIndex, keyId)`. The identity row + /// belongs to a different wallet (or a stale mnemonic). + /// Repair is destructive — only delete-identity. + case orphan(reason: String) + } + + var iconName: String { + switch status { + case .healthy: return "checkmark.circle.fill" + case .needsRederive: return "exclamationmark.triangle.fill" + case .orphan: return "xmark.circle.fill" + } + } + + var iconColor: Color { + switch status { + case .healthy: return .green + case .needsRederive: return .orange + case .orphan: return .red + } + } + + var statusLabel: String { + switch status { + case .healthy: return "Healthy" + case .needsRederive(let reason): return "Needs re-derive — \(reason)" + case .orphan(let reason): return "Orphan — \(reason)" + } + } + + var purposeName: String { + KeyPurpose(rawValue: purposeRaw)?.name ?? "Purpose \(purposeRaw)" + } + + var securityLevelName: String { + SecurityLevel(rawValue: securityLevelRaw)?.name ?? "Level \(securityLevelRaw)" + } +} + +/// Per-identity rollup. Severity is the highest-severity child key +/// status; drives which repair action is offered (re-derive vs +/// delete-identity). +struct WalletIdentityKeyHealthReport: Identifiable { + let id: Data // identity id bytes + let identityIdBase58: String + let identityIndex: UInt32 + let keys: [WalletKeyHealth] + /// Held so the delete-identity action can cascade-remove the row. + let identityRow: PersistentIdentity + + enum Severity { case healthy, needsRederive, orphan } + + var severity: Severity { + if keys.contains(where: { if case .orphan = $0.status { true } else { false } }) { + return .orphan + } + if keys.contains(where: { if case .needsRederive = $0.status { true } else { false } }) { + return .needsRederive + } + return .healthy + } +} + +// MARK: - Checker / repair + +/// Construct the new (walletId-namespaced) keychain account name a +/// key SHOULD be stored under. Mirrors `KeychainManager.storeIdentityPrivateKey`. +/// Pulled out here so the health check can do strict direct lookups +/// instead of falling through to the metadata-scan path that also +/// matches legacy entries. +fileprivate func namespacedKeychainAccount(walletIdHex: String, derivationPath: String) -> String { + "identity_privkey.\(walletIdHex).\(derivationPath)" +} + +/// Construct the OLD (no-walletId) keychain account name. Used only +/// for the post-rederive cleanup sweep. +fileprivate func legacyKeychainAccount(derivationPath: String) -> String { + "identity_privkey.\(derivationPath)" +} + +/// Best-effort delete of the legacy (no-walletId) keychain entry for +/// `derivationPath`, gated on its metadata's `walletId` matching the +/// wallet we just migrated from. Skips silently if the metadata says +/// the row belongs to a different wallet — never clobber data we +/// don't own. No-op when the row doesn't exist. +/// +/// Called after a successful re-derive in +/// `WalletKeyHealthChecker.rederive` so the keychain ends up with a +/// single new-format entry per `(wallet, path)`. +@MainActor +fileprivate func cleanupLegacyKeychainEntry(walletIdHex: String, derivationPath: String) { + let account = legacyKeychainAccount(derivationPath: derivationPath) + let serviceName = KeychainManager.shared.serviceName + let lookupQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let lookupStatus = SecItemCopyMatching(lookupQuery as CFDictionary, &result) + guard lookupStatus == errSecSuccess, let attrs = result as? [String: Any] else { + return + } + guard let metadataData = attrs[kSecAttrGeneric as String] as? Data, + let metadata = try? JSONDecoder().decode( + IdentityPrivateKeyMetadata.self, + from: metadataData + ) + else { + return + } + guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { + // Different wallet's data sitting at the legacy account — + // leave it. (Pathological since we'd have to have lost the + // namespaced fix at some point, but defending it is cheap.) + return + } + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + ] + _ = SecItemDelete(deleteQuery as CFDictionary) +} + +/// Pure helpers — no UI state. Constructed lazily inside the sheet's +/// `.task { … }` so the heavy derivation + Keychain scans happen on a +/// background context. +enum WalletKeyHealthChecker { + + /// Derive each PersistentPublicKey's canonical key from the + /// wallet's mnemonic and classify against the stored row + + /// Keychain bytes. Pure read — does not mutate state. + @MainActor + static func runCheck( + wallet: ManagedPlatformWallet, + walletId: Data, + identities: [PersistentIdentity], + network: Network + ) -> [WalletIdentityKeyHealthReport] { + var reports: [WalletIdentityKeyHealthReport] = [] + for identity in identities { + let sortedKeys = identity.publicKeys.sorted { $0.keyId < $1.keyId } + var keyHealths: [WalletKeyHealth] = [] + for row in sortedKeys { + let kid = UInt32(bitPattern: row.keyId) + let purposeRaw = UInt8(row.purpose) ?? 0 + let levelRaw = UInt8(row.securityLevel) ?? 0 + let storedHex = row.publicKeyData.toHexString() + + let status: WalletKeyHealth.Status + let derivedHex: String + + do { + let preview = try wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: identity.identityIndex, + keyId: kid, + network: network + ) + derivedHex = preview.publicKeyHex + + if preview.publicKeyData == row.publicKeyData { + // pubkey matches the wallet's mnemonic → + // look up the keychain bytes at the expected + // walletId-namespaced account. + // + // We intentionally DON'T fall back to the + // metadata-scan lookup + // (`retrieveIdentityPrivateKey(publicKeyHex:)`) + // here — that path also finds legacy + // (non-namespaced) entries, which would + // mask migration debt. Reporting legacy-only + // keys as "needsRederive" surfaces them in + // the sheet so the user can migrate them + // explicitly. + let expectedAccount = namespacedKeychainAccount( + walletIdHex: walletId.toHexString(), + derivationPath: preview.derivationPath + ) + if let kcBytes = KeychainManager.shared + .retrieveKeyData(identifier: expectedAccount) + { + if kcBytes == preview.privateKeyData { + status = .healthy + } else { + status = .needsRederive( + reason: "Keychain bytes at \(expectedAccount.suffix(40)) don't match the derived private key" + ) + } + } else { + status = .needsRederive( + reason: "No new-format Keychain entry (legacy-only entries don't count)" + ) + } + } else { + status = .orphan( + reason: "Stored pubkey \(storedHex.prefix(12))… doesn't match wallet's derivation \(derivedHex.prefix(12))…" + ) + } + } catch { + derivedHex = "" + status = .orphan( + reason: "Derivation failed: \(error.localizedDescription)" + ) + } + + keyHealths.append( + WalletKeyHealth( + id: row.keyId, + keyId: kid, + purposeRaw: purposeRaw, + securityLevelRaw: levelRaw, + storedPublicKeyHex: storedHex, + derivedPublicKeyHex: derivedHex, + status: status, + row: row + ) + ) + } + reports.append( + WalletIdentityKeyHealthReport( + id: identity.identityId, + identityIdBase58: identity.identityIdBase58, + identityIndex: identity.identityIndex, + keys: keyHealths, + identityRow: identity + ) + ) + } + return reports + } + + /// Re-derive every key in `report` whose status is + /// `.needsRederive`, write fresh Keychain entries (at the new + /// walletId-namespaced account), and update each + /// `PersistentPublicKey.privateKeyKeychainIdentifier` to point + /// at the new account. Returns the number of keys fixed. + @MainActor + static func rederive( + report: WalletIdentityKeyHealthReport, + wallet: ManagedPlatformWallet, + walletId: Data, + network: Network, + modelContext: ModelContext + ) throws -> Int { + var fixed = 0 + for key in report.keys { + guard case .needsRederive = key.status else { continue } + let preview = try wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: report.identityIndex, + keyId: key.keyId, + network: network + ) + let pubkeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(preview.publicKeyData) + let metadata = IdentityPrivateKeyMetadata( + identityId: report.identityIdBase58, + keyId: key.keyId, + walletId: walletId.toHexString(), + identityIndex: report.identityIndex, + keyIndex: key.keyId, + derivationPath: preview.derivationPath, + publicKey: preview.publicKeyHex, + publicKeyHash: pubkeyHashHex, + keyType: UInt8(key.row.keyType) ?? 0, + purpose: key.purposeRaw, + securityLevel: key.securityLevelRaw + ) + guard let pkid = KeychainManager.shared.storeIdentityPrivateKey( + preview.privateKeyData, + derivationPath: preview.derivationPath, + metadata: metadata + ) else { + continue + } + key.row.privateKeyKeychainIdentifier = pkid + + // Sweep the legacy (no-walletId) account for the same + // derivation path. Only delete if its metadata's + // walletId matches THIS wallet — defensive guard against + // ever clobbering another wallet's keys, in case a + // future collision lands data we don't own at that + // account. + cleanupLegacyKeychainEntry( + walletIdHex: walletId.toHexString(), + derivationPath: preview.derivationPath + ) + fixed += 1 + } + if fixed > 0 { + try modelContext.save() + } + return fixed + } + + /// Cascade-delete an orphan identity from SwiftData. Safe to + /// call now that the relationship inverses + /// (`PersistentPublicKey.identity`, `PersistentDPNSName.identity`, + /// `PersistentDashpayProfile.identity`, `PersistentDashpayContactRequest.owner`) + /// are all Optional — see the doc comments on those models for + /// the SwiftData cascade-on-non-optional crash this avoids. + @MainActor + static func deleteOrphan( + identity: PersistentIdentity, + modelContext: ModelContext + ) throws { + modelContext.delete(identity) + try modelContext.save() + } +} + +// MARK: - View + +/// Modal sheet that runs the health check on appear and renders the +/// per-identity / per-key result, with one-tap repair actions. +struct WalletKeyHealthSheet: View { + let wallet: ManagedPlatformWallet + let walletId: Data + /// Snapshotted at construction so the sheet shows what was true + /// when opened, not what changes underneath. Re-fetch by closing + /// and re-opening. + let identities: [PersistentIdentity] + let network: Network + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var reports: [WalletIdentityKeyHealthReport] = [] + @State private var isRunning = false + @State private var actionMessage: String? + @State private var errorMessage: String? + @State private var pendingOrphanDelete: WalletIdentityKeyHealthReport? + + var body: some View { + NavigationView { + Form { + if isRunning { + Section { + HStack { + ProgressView() + Text("Checking keys…") + .foregroundColor(.secondary) + } + } + } else if reports.isEmpty { + Section { + Text("No identities to check.") + .foregroundColor(.secondary) + } + } else { + summarySection + ForEach(reports) { report in + identitySection(report) + } + } + if let actionMessage { + Section { + Text(actionMessage) + .font(.caption) + .foregroundColor(.green) + } + } + if let errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + .navigationTitle("Verify Identity Keys") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + .task { await runCheck() } + .alert( + "Delete Identity", + isPresented: Binding( + get: { pendingOrphanDelete != nil }, + set: { if !$0 { pendingOrphanDelete = nil } } + ) + ) { + Button("Cancel", role: .cancel) { pendingOrphanDelete = nil } + Button("Delete", role: .destructive) { + if let report = pendingOrphanDelete { + deleteIdentity(report) + pendingOrphanDelete = nil + } + } + } message: { + if let report = pendingOrphanDelete { + Text( + "Identity \(report.identityIdBase58.prefix(12))… doesn't match this wallet's mnemonic. " + + "Deleting it removes the SwiftData row and all associated keys / DashPay state. " + + "The on-chain identity is unaffected." + ) + } + } + } + } + + @ViewBuilder + private var summarySection: some View { + Section("Summary") { + HStack { + Text("Identities checked") + Spacer() + Text("\(reports.count)").foregroundColor(.secondary) + } + let healthy = reports.filter { $0.severity == .healthy }.count + let needs = reports.filter { $0.severity == .needsRederive }.count + let orphans = reports.filter { $0.severity == .orphan }.count + HStack { + Label("Healthy", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + Spacer() + Text("\(healthy)").foregroundColor(.secondary) + } + HStack { + Label("Needs re-derive", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Spacer() + Text("\(needs)").foregroundColor(.secondary) + } + HStack { + Label("Orphan", systemImage: "xmark.circle.fill") + .foregroundColor(.red) + Spacer() + Text("\(orphans)").foregroundColor(.secondary) + } + } + } + + @ViewBuilder + private func identitySection(_ report: WalletIdentityKeyHealthReport) -> some View { + Section { + ForEach(report.keys) { key in + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: key.iconName) + .foregroundColor(key.iconColor) + Text("Key #\(key.keyId) — \(key.purposeName), \(key.securityLevelName)") + .font(.subheadline) + .fontWeight(.medium) + Spacer() + } + Text(key.statusLabel) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 2) + } + actionRow(for: report) + } header: { + HStack { + Image(systemName: severityIcon(report.severity)) + .foregroundColor(severityColor(report.severity)) + Text("Identity \(report.identityIdBase58.prefix(12))… (idx \(report.identityIndex))") + } + } + } + + @ViewBuilder + private func actionRow(for report: WalletIdentityKeyHealthReport) -> some View { + switch report.severity { + case .healthy: + EmptyView() + case .needsRederive: + Button { + rederive(report) + } label: { + Label("Re-derive missing keys", systemImage: "arrow.triangle.2.circlepath") + .foregroundColor(.blue) + } + case .orphan: + Button(role: .destructive) { + pendingOrphanDelete = report + } label: { + Label("Delete this identity", systemImage: "trash") + } + } + } + + private func severityIcon(_ s: WalletIdentityKeyHealthReport.Severity) -> String { + switch s { + case .healthy: return "checkmark.circle.fill" + case .needsRederive: return "exclamationmark.triangle.fill" + case .orphan: return "xmark.circle.fill" + } + } + + private func severityColor(_ s: WalletIdentityKeyHealthReport.Severity) -> Color { + switch s { + case .healthy: return .green + case .needsRederive: return .orange + case .orphan: return .red + } + } + + // MARK: Actions + + @MainActor + private func runCheck() async { + isRunning = true + defer { isRunning = false } + actionMessage = nil + errorMessage = nil + reports = WalletKeyHealthChecker.runCheck( + wallet: wallet, + walletId: walletId, + identities: identities, + network: network + ) + } + + private func rederive(_ report: WalletIdentityKeyHealthReport) { + Task { @MainActor in + do { + let fixed = try WalletKeyHealthChecker.rederive( + report: report, + wallet: wallet, + walletId: walletId, + network: network, + modelContext: modelContext + ) + actionMessage = "Re-derived \(fixed) key\(fixed == 1 ? "" : "s") for identity \(report.identityIdBase58.prefix(12))…" + errorMessage = nil + // Re-run the check so the report reflects the new + // state (formerly-orange rows should turn green). + await runCheck() + } catch { + errorMessage = "Re-derive failed: \(error.localizedDescription)" + } + } + } + + private func deleteIdentity(_ report: WalletIdentityKeyHealthReport) { + do { + try WalletKeyHealthChecker.deleteOrphan( + identity: report.identityRow, + modelContext: modelContext + ) + actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…" + errorMessage = nil + // Drop the now-deleted row from the report list so the + // sheet doesn't try to render it again. + reports.removeAll { $0.id == report.id } + } catch { + errorMessage = "Delete failed: \(error.localizedDescription)" + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift index fe641a9888d..086fa0a849b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift @@ -52,7 +52,6 @@ struct AddIdentityKeyView: View { @State private var keyType: KeyType = .ecdsaSecp256k1 @State private var purpose: KeyPurpose = .authentication @State private var authSecurityLevel: SecurityLevel = .high - @State private var encryptionSecurityLevel: SecurityLevel = .high /// Selected contract id for Encryption / Decryption bounds, in /// the canonical 32-byte form. `nil` until the user picks one. @State private var boundContractId: Data? @@ -91,28 +90,123 @@ struct AddIdentityKeyView: View { allContracts.filter { $0.network == appState.currentNetwork } } - /// Document-type names declared by the currently-selected - /// contract. Drives the optional document-type picker. - private var documentTypesForSelectedContract: [String] { - guard let id = boundContractId, - let contract = contractsForNetwork.first(where: { $0.id == id }) - else { - return [] + /// System contracts that declare `requiresIdentityEncryptionBoundedKey` + /// somewhere — either contract-level or on at least one document + /// type. These are network-agnostic (same canonical 32-byte ID + /// on every network) so they're always selectable in the bounds + /// picker — the user shouldn't have to fetch them onto the + /// device first just to bind an encryption key to them. + /// + /// Today only DashPay qualifies (its `contactRequest` document + /// type declares the requirement). DPNS / withdrawals / + /// masternode-reward-shares / token-history / keyword-search + /// don't bind encryption keys. + /// + /// **Important:** DPP rejects `ContractBounds::SingleContract` + /// unless the *contract config* itself declares the requirement + /// (see `packages/rs-drive-abci/.../validate_identity_public_key_contract_bounds/v0/mod.rs:82`). + /// DashPay declares it only at the document-type level, so + /// `allowsContractScope` is `false` and the document-type + /// picker is required (not optional). + /// + /// ID source: `packages/dashpay-contract/src/lib.rs::ID_BYTES`. + /// Document-type names: `packages/dashpay-contract/schema/v1/dashpay.schema.json`. + private static let systemContractsAllowingKeyBounds: [SystemContractEntry] = [ + SystemContractEntry( + name: "DashPay", + id: Data([ + 162, 161, 180, 172, 111, 239, 34, 234, + 42, 26, 104, 232, 18, 54, 68, 179, + 87, 135, 95, 107, 65, 44, 24, 16, + 146, 129, 193, 70, 231, 178, 113, 188, + ]), + allowsContractScope: false, + documentTypesAllowingBounds: ["contactRequest"] + ), + ] + + /// Combined picker entries: system contracts first (always + /// available), then user-saved contracts filtered to the + /// current network. Saved-contract rows that happen to match + /// a system contract by ID are skipped to avoid a duplicate. + /// + /// Caveat: for user-saved (`PersistentDataContract`) entries + /// we don't currently know which document types declare the + /// bounded-key requirement, so we conservatively expose ALL + /// of them and allow contract-scope. Picking an invalid + /// combination there will fail at submit time with a DPP + /// validation error. Tightening this would require parsing + /// the per-document-type schema flag into SwiftData rows. + private var pickerEntries: [BoundsPickerEntry] { + let savedIds = Set(contractsForNetwork.map(\.id)) + let system = Self.systemContractsAllowingKeyBounds + .filter { !savedIds.contains($0.id) } + .map { + BoundsPickerEntry( + id: $0.id, + displayName: "\($0.name) (System)", + allowsContractScope: $0.allowsContractScope, + documentTypesAllowingBounds: $0.documentTypesAllowingBounds + ) + } + let saved = contractsForNetwork.map { + BoundsPickerEntry( + id: $0.id, + displayName: $0.name, + allowsContractScope: true, + documentTypesAllowingBounds: ($0.documentTypes ?? []).map(\.name).sorted() + ) } - return (contract.documentTypes ?? []).map { $0.name }.sorted() + return system + saved + } + + /// The currently-selected picker entry, if any. + private var selectedEntry: BoundsPickerEntry? { + guard let id = boundContractId else { return nil } + return pickerEntries.first { $0.id == id } } - /// Effective security level for the current purpose. Transfer - /// is locked at Critical per the form's contract; everything - /// else picks from the user's selected value. + /// Document-type names valid for binding on the selected contract. + private var documentTypesForSelectedContract: [String] { + selectedEntry?.documentTypesAllowingBounds ?? [] + } + + /// Whether the user must pick a document type (i.e. the selected + /// contract does not allow a contract-scope binding). Drives + /// the "Any document type" option visibility and gates submit. + private var documentTypeRequired: Bool { + guard let entry = selectedEntry else { return false } + return !entry.allowsContractScope + } + + /// Effective security level for the current purpose. Several + /// purposes are protocol-locked: + /// - `transfer` → Critical + /// - `encryption`/`decryption` → Medium (DPP enforces only + /// `SecurityLevel::MEDIUM`; see + /// `validate_identity_public_keys_structure/v0/mod.rs`) + /// Auth-style purposes (Authentication today) are user-pickable. private var effectiveSecurityLevel: SecurityLevel { switch purpose { case .transfer: return .critical - case .encryption, .decryption: return encryptionSecurityLevel + case .encryption, .decryption: return .medium default: return authSecurityLevel } } + /// Effective key type for the current purpose. ENCRYPTION / + /// DECRYPTION are locked to ECDSA secp256k1: the rest of the + /// stack does ECDH via `dashcore::secp256k1::PublicKey` (see + /// `packages/rs-platform-wallet/.../contacts.rs`), HASH160 + /// stores only the hash so there's no full pubkey to ECDH + /// against, and BLS is the wrong curve. + private var effectiveKeyType: KeyType { + switch purpose { + case .encryption, .decryption: return .ecdsaSecp256k1 + default: return keyType + } + } + /// `keyId` to assign to the new key — `max(existing) + 1`. /// Auto-assigned (the user can't pick) so the new key never /// collides with an existing slot. @@ -124,28 +218,31 @@ struct AddIdentityKeyView: View { /// Whether Encryption / Decryption purposes are missing their /// required contract bounds. Surfaces a disabled-Submit hint /// rather than letting the user submit a doomed transition. + /// Also covers the case where the selected contract requires a + /// document type (DashPay does) but the user hasn't picked one. private var contractBoundsMissing: Bool { switch purpose { case .encryption, .decryption: - return boundContractId == nil + guard boundContractId != nil else { return true } + if documentTypeRequired { + let trimmed = boundDocumentTypeName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + } + return false default: return false } } private var canSubmit: Bool { - !isSubmitting && keyType != .bls12_381 && !contractBoundsMissing + !isSubmitting && effectiveKeyType != .bls12_381 && !contractBoundsMissing } var body: some View { NavigationStack { Form { Section("New Key") { - Picker("Key Type", selection: $keyType) { - ForEach(Self.pickableKeyTypes, id: \.self) { type in - Text(type.name).tag(type) - } - } + keyTypePicker Picker("Purpose", selection: $purpose) { ForEach(Self.pickablePurposes, id: \.self) { p in Text(p.name).tag(p) @@ -167,7 +264,7 @@ struct AddIdentityKeyView: View { .foregroundColor(.secondary) } - if keyType == .bls12_381 { + if effectiveKeyType == .bls12_381 { Section { Label( "BLS derivation is not yet wired through the FFI for this flow. Pick ECDSA secp256k1 or ECDSA Hash160 to add a key now.", @@ -222,17 +319,53 @@ struct AddIdentityKeyView: View { .onChange(of: boundContractId) { _, _ in // Switching contracts invalidates the document-type // selection (different contracts have different - // schemas). - boundDocumentTypeName = "" + // schemas). When the new contract requires a + // document-type binding and only one valid choice + // exists, auto-pick it so the user can submit + // immediately (DashPay → `contactRequest`). When + // contract-scope is allowed, default to the empty + // "Any document type" option. + if let entry = selectedEntry, + !entry.allowsContractScope, + entry.documentTypesAllowingBounds.count == 1 + { + boundDocumentTypeName = entry.documentTypesAllowingBounds[0] + } else { + boundDocumentTypeName = "" + } } } } // MARK: Sub-views - /// Security-level picker. Hidden + auto-Critical for Transfer - /// (per the form's contract); pickable for Auth / Encryption / - /// Decryption with Master excluded. + /// Key-type picker. Locked to ECDSA secp256k1 for Encryption / + /// Decryption purposes since ECDH only works against a full + /// secp256k1 pubkey. Pickable for all other purposes; the + /// `effectiveKeyType` computed property carries the locked + /// value through to the submit path. + @ViewBuilder + private var keyTypePicker: some View { + switch purpose { + case .encryption, .decryption: + LabeledContent("Key Type") { + Text(KeyType.ecdsaSecp256k1.name) + .foregroundColor(.secondary) + } + default: + Picker("Key Type", selection: $keyType) { + ForEach(Self.pickableKeyTypes, id: \.self) { type in + Text(type.name).tag(type) + } + } + } + } + + /// Security-level picker. Locked rows for purposes the protocol + /// constrains to a single level (Transfer → Critical; + /// Encryption / Decryption → Medium per DPP + /// `validate_identity_public_keys_structure/v0/mod.rs`). Pickable + /// for Auth-style purposes with Master excluded. @ViewBuilder private var securityLevelPicker: some View { switch purpose { @@ -242,10 +375,9 @@ struct AddIdentityKeyView: View { .foregroundColor(.secondary) } case .encryption, .decryption: - Picker("Security Level", selection: $encryptionSecurityLevel) { - ForEach(Self.nonMasterSecurityLevels, id: \.self) { lvl in - Text(lvl.name).tag(lvl) - } + LabeledContent("Security Level") { + Text("Medium") + .foregroundColor(.secondary) } default: Picker("Security Level", selection: $authSecurityLevel) { @@ -262,27 +394,48 @@ struct AddIdentityKeyView: View { @ViewBuilder private var contractBoundsSection: some View { Section("Contract Bounds (required)") { - if contractsForNetwork.isEmpty { - Text("No contracts saved on this device. Add or fetch a contract on the Contracts tab first.") - .font(.caption) - .foregroundColor(.orange) - } else { - Picker("Contract", selection: $boundContractId) { - Text("Select a contract").tag(Data?.none) - ForEach(contractsForNetwork, id: \.id) { contract in - Text(contract.name).tag(Optional(contract.id)) - } + // `pickerEntries` always contains the system-contract + // entries (DashPay today), so the picker is never empty + // even on a fresh install. The Contracts tab is still + // the way to add non-system contracts to the list. + Picker("Contract", selection: $boundContractId) { + Text("Select a contract").tag(Data?.none) + ForEach(pickerEntries, id: \.id) { entry in + Text(entry.displayName).tag(Optional(entry.id)) } + } - if !documentTypesForSelectedContract.isEmpty { - Picker("Document Type (optional)", selection: $boundDocumentTypeName) { + if !documentTypesForSelectedContract.isEmpty { + // When the contract permits a contract-scope bound + // (the `requires_identity_encryption_bounded_key` + // flag is set on the contract config itself), the + // user can leave the document type unset → results + // in `SingleContract` bounds. When it isn't set — + // DashPay's case, where the flag lives only on + // `contactRequest` — the user MUST pick one of the + // listed document types. DPP rejects + // `SingleContract` bounds against DashPay with + // `DataContractBoundsNotPresentError`. + Picker( + documentTypeRequired + ? "Document Type (required)" + : "Document Type (optional)", + selection: $boundDocumentTypeName + ) { + if !documentTypeRequired { Text("Any document type").tag("") - ForEach(documentTypesForSelectedContract, id: \.self) { name in - Text(name).tag(name) - } + } + ForEach(documentTypesForSelectedContract, id: \.self) { name in + Text(name).tag(name) } } + } + if documentTypeRequired { + Text("This contract requires the key to be bound to a specific document type — picking a contract scope alone would be rejected at submit.") + .font(.caption) + .foregroundColor(.secondary) + } else { Text("Encryption / decryption keys must be scoped to a specific contract. A document type narrows the scope further; leaving it blank lets the key operate across all of the contract's document types.") .font(.caption) .foregroundColor(.secondary) @@ -305,6 +458,11 @@ struct AddIdentityKeyView: View { let network = appState.sdk?.network ?? .testnet let chosenKeyId = nextKeyId let chosenSecurityLevel = effectiveSecurityLevel + // Use the effective (purpose-aware) key type so encryption / + // decryption submissions always carry ECDSA secp256k1 + // regardless of what's in the `keyType` @State from a prior + // purpose selection. + let chosenKeyType = effectiveKeyType // Build the contract bounds shape from the picker state. // Encryption / Decryption are gated above so we can assume @@ -377,7 +535,7 @@ struct AddIdentityKeyView: View { let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(preview.publicKeyData) let metadataPublicKeyHex: String = - keyType == .ecdsaHash160 ? pubKeyHashHex : preview.publicKeyHex + chosenKeyType == .ecdsaHash160 ? pubKeyHashHex : preview.publicKeyHex let metadata = IdentityPrivateKeyMetadata( identityId: identity.identityIdString, keyId: chosenKeyId, @@ -387,7 +545,7 @@ struct AddIdentityKeyView: View { derivationPath: preview.derivationPath, publicKey: metadataPublicKeyHex, publicKeyHash: pubKeyHashHex, - keyType: keyType.rawValue, + keyType: chosenKeyType.rawValue, purpose: purpose.rawValue, securityLevel: chosenSecurityLevel.rawValue ) @@ -421,7 +579,7 @@ struct AddIdentityKeyView: View { // matches the selected key type's expected byte // length. let pubkeyBytesForFFI: Data - if keyType == .ecdsaHash160 { + if chosenKeyType == .ecdsaHash160 { guard let hashBytes = Data(hexString: pubKeyHashHex), hashBytes.count == 20 else { isSubmitting = false errorMessage = @@ -435,7 +593,7 @@ struct AddIdentityKeyView: View { let newKey = ManagedPlatformWallet.IdentityPubkey( keyId: chosenKeyId, - keyType: keyType, + keyType: chosenKeyType, purpose: purpose, securityLevel: chosenSecurityLevel, pubkeyBytes: pubkeyBytesForFFI, @@ -457,3 +615,36 @@ struct AddIdentityKeyView: View { } } } + +// MARK: - Picker support types + +/// A system data contract that should always be available in the +/// contract-bounds picker. Network-agnostic — system contracts have +/// the same canonical 32-byte ID on every network. +/// +/// `allowsContractScope` mirrors the contract-level +/// `requiresIdentityEncryptionBoundedKey` flag — when `false`, +/// `ContractBounds::SingleContract` is rejected by DPP and the +/// picker has to force a document-type selection. +/// +/// `documentTypesAllowingBounds` lists only the document types +/// that themselves declare `requiresIdentityEncryptionBoundedKey`, +/// since picking any other DT would fail DPP validation with +/// `DataContractBoundsNotPresentError`. +private struct SystemContractEntry { + let name: String + let id: Data + let allowsContractScope: Bool + let documentTypesAllowingBounds: [String] +} + +/// Unified row shape for the bounds picker — covers both the static +/// `SystemContractEntry` registry and per-network +/// `PersistentDataContract` rows. Lets the picker iterate one list +/// regardless of origin. +private struct BoundsPickerEntry { + let id: Data + let displayName: String + let allowsContractScope: Bool + let documentTypesAllowingBounds: [String] +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 5b9088424c8..394cf51e58f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -48,6 +48,34 @@ struct CreateIdentityView: View { /// here yet. private static let defaultKeyCount: UInt32 = 3 + /// Number of extra keys added when `addDashPayKeys` is on + /// (encryption + decryption). + private static let dashPayExtraKeyCount: UInt32 = 2 + + /// Per-key cost in credits (from + /// `IdentityCreateTransition::calculate_min_required_fee_v1`). + /// Used to scale the asset-lock minimum when extra keys are + /// requested. + private static let identityKeyCreationCostCredits: UInt64 = 6_500_000 + + /// DashPay system contract id (32 bytes), source-of-truth + /// `packages/dashpay-contract/src/lib.rs::ID_BYTES`. Mirrored + /// here so the contract-bounds payload for the optional + /// encryption/decryption keys can be built without a contract + /// fetch round-trip. Also matches the registry in + /// `AddIdentityKeyView.systemContractsAllowingKeyBounds`. + private static let dashpayContractId = Data([ + 162, 161, 180, 172, 111, 239, 34, 234, + 42, 26, 104, 232, 18, 54, 68, 179, + 87, 135, 95, 107, 65, 44, 24, 16, + 146, 129, 193, 70, 231, 178, 113, 188, + ]) + + /// DashPay document type these keys are bound to — the only + /// one in the contract that declares + /// `requiresIdentityEncryptionBoundedKey`. + private static let dashpayContactRequestDocumentType = "contactRequest" + /// Credits per DASH (1e11) — the divisor used for Platform-side /// credit amounts. Duplicated from `PersistentPlatformAddress` /// docstring; kept here so the conversion logic stays local. @@ -67,9 +95,33 @@ struct CreateIdentityView: View { /// The v0 floor was 200_000 duffs but with key_in_creation_cost /// dynamic at v1, the per-key surcharge has to be added. Submitting /// below this gets rejected by Platform with - /// `IdentityAssetLockTransactionOutPointNotEnoughBalance`. Keep this - /// in sync if `defaultKeyCount` changes. - private static let minIdentityFundingDuffs: UInt64 = 221_500 + /// `IdentityAssetLockTransactionOutPointNotEnoughBalance`. Use + /// `minFundingDuffs(forKeyCount:)` for the active per-flow value + /// when extra keys (e.g. the DashPay encryption/decryption pair) + /// bump the per-key surcharge. + private static let minIdentityFundingDuffsForDefaultKeys: UInt64 = 221_500 + + /// Recompute the minimum-funding floor for `keyCount` total + /// identity keys. Formula above; result is in duffs (credits ÷ 1000). + private static func minFundingDuffs(forKeyCount keyCount: UInt32) -> UInt64 { + let base: UInt64 = 2_000_000 + let assetLockBaseCredits: UInt64 = 200_000_000 + let perKey = identityKeyCreationCostCredits * UInt64(keyCount) + return (base + assetLockBaseCredits + perKey) / 1_000 + } + + /// Total identity keys this submission will register, given the + /// current DashPay-keys toggle. Drives the asset-lock minimum + /// + the on-screen min-funding hint. + private var plannedIdentityKeyCount: UInt32 { + Self.defaultKeyCount + (addDashPayKeys ? Self.dashPayExtraKeyCount : 0) + } + + /// Per-submission minimum funding in duffs — scales with + /// `plannedIdentityKeyCount`. + private var currentMinFundingDuffs: UInt64 { + Self.minFundingDuffs(forKeyCount: plannedIdentityKeyCount) + } /// Default funding amount pre-filled into the field for the Core /// path. Sits 12.5% above the protocol minimum to give headroom @@ -120,6 +172,18 @@ struct CreateIdentityView: View { /// first unused index; the user can override via the picker. @State private var identityIndex: UInt32? = nil + /// Register the optional pair of DashPay encryption/decryption + /// keys (kid=3 ENCRYPTION + kid=4 DECRYPTION, both MEDIUM + /// security, both ECDSA-secp256k1, both bound to the DashPay + /// system contract's `contactRequest` document type) at + /// identity-creation time. Default-on because the rest of the + /// app's DashPay flow (contact requests, payments, profile) + /// needs these keys present before it'll work — without them + /// the user would have to come back to "Add Identity Key" and + /// register them as a follow-up step. The two extra keys add + /// 13_000 duffs to the asset-lock minimum. + @State private var addDashPayKeys: Bool = true + /// Raw asset-lock proof text, used only in the walletless path. /// Accepted encoding is base64 or lowercase hex — the submit /// logic (future) will detect + decode. @@ -228,6 +292,7 @@ struct CreateIdentityView: View { fundingSection amountSection identityIndexSection + dashpayKeysSection if canSubmit { submitSection } @@ -501,7 +566,7 @@ struct CreateIdentityView: View { divisor: Double(Self.duffsPerDash) ) let minimum = Self.formatDash( - raw: Self.minIdentityFundingDuffs, + raw: currentMinFundingDuffs, divisor: Double(Self.duffsPerDash) ) Text("Available: \(available). Minimum: \(minimum). Rust builds an asset-lock transaction from your Core UTXOs and the locked funds become the new identity's initial credit balance.") @@ -594,6 +659,32 @@ struct CreateIdentityView: View { } } + /// Toggle for the optional DashPay encryption/decryption key + /// pair. Default-on because DashPay is a first-class feature in + /// this app and registering the keys after-the-fact requires + /// another state transition (Add Identity Key). + @ViewBuilder + private var dashpayKeysSection: some View { + // Only meaningful for wallet-backed paths — walletless / + // resume paths don't pre-derive at custom indices today. + if case .wallet = walletSelection, + fundingSelection != .unusedAssetLock { + Section { + Toggle("Register DashPay keys", isOn: $addDashPayKeys) + } header: { + Text("DashPay Support") + } footer: { + let extraDuffs = currentMinFundingDuffs + - Self.minFundingDuffs(forKeyCount: Self.defaultKeyCount) + Text( + addDashPayKeys + ? "Registers 2 additional keys at registration — one Encryption + one Decryption (both MEDIUM security, ECDSA secp256k1, bound to the DashPay system contract's `contactRequest` document type). Required for sending and accepting friend requests, sending payments to contacts, and DashPay profile flows. Adds \(extraDuffs) duffs to the asset-lock minimum." + : "Identity will register with the default 3 authentication keys only. You can add DashPay encryption/decryption keys later via Add Identity Key on the identity detail screen — but flows like Add Friend won't work until those keys exist." + ) + } + } + } + private var submitSection: some View { // The active progress / success / error UI lives on the // pushed `RegistrationProgressView` destination, NOT @@ -678,7 +769,7 @@ struct CreateIdentityView: View { if let account = selectedCoreAccount { guard let duffs = parsedAmountDuffs else { return false } let available = coreAccountBalanceDuffs(account) - return duffs >= Self.minIdentityFundingDuffs && duffs <= available + return duffs >= currentMinFundingDuffs && duffs <= available } return false default: @@ -729,7 +820,7 @@ struct CreateIdentityView: View { // buffers immediately. The mnemonic stays in Keychain; the // derivation path lives on `PersistentPlatformAddress`. let signer = KeychainSigner(modelContainer: modelContext.container) - let identityPubkeys: [ManagedPlatformWallet.IdentityPubkey] + var identityPubkeys: [ManagedPlatformWallet.IdentityPubkey] do { // Single-FFI derive + persist. The Rust side owns the // per-key MASTER-vs-HIGH policy and ships back the @@ -751,6 +842,40 @@ struct CreateIdentityView: View { return } + // Optional DashPay key pair — Encryption (kid=N) + Decryption + // (kid=N+1) bound to DashPay's `contactRequest` document + // type, MEDIUM security level, ECDSA-secp256k1. Registered + // alongside the default auth keys when the user has + // `addDashPayKeys` on (default) so the post-registration + // DashPay flow (send contact request, accept, send payment) + // is usable without a follow-up Add Identity Key round. + // + // The Rust pre-persist call above only knows about the + // auth-key policy (master + HIGH). For the encryption / + // decryption pair we re-use the same per-slot derivation + // FFI the manual Add Identity Key flow uses, build the + // matching `IdentityPubkey` rows here with explicit + // `contractBounds`, and store the private bytes under the + // walletId-namespaced keychain account so the trampoline + // can find them when DashPay flows ask the new keys to + // sign. + if addDashPayKeys { + do { + identityPubkeys.append(contentsOf: try makeDashpayKeyPair( + managedWallet: managedWallet, + walletId: walletId, + identityIndex: identityIndex, + firstKeyId: Self.defaultKeyCount, + network: platformState.currentNetwork + )) + } catch { + submitError = .init( + message: "Could not derive DashPay keys: \(error.localizedDescription)" + ) + return + } + } + let network: Network = platformState.currentNetwork // Dispatch by funding source. @@ -1238,6 +1363,91 @@ struct CreateIdentityView: View { account.platformAddresses.reduce(0) { $0 + $1.balance } } + /// Derive + Keychain-persist the DashPay encryption/decryption + /// key pair (kid `firstKeyId` = ENCRYPTION, kid `firstKeyId+1` = + /// DECRYPTION), build the matching `IdentityPubkey` rows with + /// contract bounds pointing at the DashPay system contract's + /// `contactRequest` document type, and return both. Throws on + /// any derivation / keychain-write failure so the caller can + /// surface the error inline and bail out of the registration. + /// + /// Conceptually this is the per-key body that + /// `prePersistIdentityKeysForRegistration` would emit if Rust + /// owned the DashPay-purpose policy too — we keep it Swift-side + /// for now because the Rust function's per-slot purpose table + /// is hardcoded to the auth pattern (MASTER + HIGH×N). + private func makeDashpayKeyPair( + managedWallet: ManagedPlatformWallet, + walletId: Data, + identityIndex: UInt32, + firstKeyId: UInt32, + network: Network + ) throws -> [ManagedPlatformWallet.IdentityPubkey] { + let purposes: [(keyId: UInt32, purpose: KeyPurpose)] = [ + (firstKeyId, .encryption), + (firstKeyId + 1, .decryption), + ] + let bounds: ManagedPlatformWallet.ContractBounds = .singleContractDocumentType( + id: Self.dashpayContractId, + documentTypeName: Self.dashpayContactRequestDocumentType + ) + let walletIdHex = walletId.toHexString() + let identityIdPlaceholder = "" // overwritten by persister callback + // when the identity actually + // lands on-chain; the metadata + // we write here only needs to + // satisfy the keychain + // round-trip lookup by pubkey + // hex. + + var rows: [ManagedPlatformWallet.IdentityPubkey] = [] + rows.reserveCapacity(purposes.count) + + for (keyId, purpose) in purposes { + let preview = try managedWallet.deriveIdentityAuthKeyAtSlot( + identityIndex: identityIndex, + keyId: keyId, + network: network + ) + let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex( + preview.publicKeyData + ) + let metadata = IdentityPrivateKeyMetadata( + identityId: identityIdPlaceholder, + keyId: keyId, + walletId: walletIdHex, + identityIndex: identityIndex, + keyIndex: keyId, + derivationPath: preview.derivationPath, + publicKey: preview.publicKeyHex, + publicKeyHash: pubKeyHashHex, + keyType: KeyType.ecdsaSecp256k1.rawValue, + purpose: purpose.rawValue, + securityLevel: SecurityLevel.medium.rawValue + ) + guard KeychainManager.shared.storeIdentityPrivateKey( + preview.privateKeyData, + derivationPath: preview.derivationPath, + metadata: metadata + ) != nil else { + throw PlatformWalletError.walletOperation( + "Could not persist DashPay key (kid \(keyId), purpose \(purpose.name)) to Keychain" + ) + } + rows.append( + ManagedPlatformWallet.IdentityPubkey( + keyId: keyId, + keyType: .ecdsaSecp256k1, + purpose: purpose, + securityLevel: .medium, + pubkeyBytes: preview.publicKeyData, + contractBounds: bounds + ) + ) + } + return rows + } + /// Spendable balance (duffs) for a Core / CoinJoin account. /// Reads live FFI-backed balance from the platform-wallet manager /// (the SwiftData per-account `balanceConfirmed` scalar isn't diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift index 776a5456317..8603946c562 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift @@ -12,7 +12,6 @@ struct FriendsView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext - @StateObject private var dashPayService = ObservableDashPayService() @State private var contacts: [DashPayContact] = [] @State private var incomingRequests: [DashPayContactRequest] = [] @State private var sentRequests: [DashPayContactRequest] = [] @@ -281,6 +280,64 @@ struct FriendsView: View { } +// MARK: - UI value types + +/// Lightweight UI model for an established DashPay contact row. +/// Built each time FriendsView reads local state off a fresh +/// ManagedIdentity snapshot — see `loadFriends()`. The cached +/// DashPay profile fields are resolved separately via +/// `wallet.getDashPayProfile(identityId:)`. +struct DashPayContact: Identifiable { + let id: Data + let displayName: String + let identityId: Data + let dpnsName: String? + let note: String? + let isHidden: Bool + + init( + id: Data, + displayName: String, + identityId: Data, + dpnsName: String? = nil, + note: String? = nil, + isHidden: Bool = false + ) { + self.id = id + self.displayName = displayName + self.identityId = identityId + self.dpnsName = dpnsName + self.note = note + self.isHidden = isHidden + } +} + +/// Lightweight UI model for an incoming or outgoing contact request +/// row. `id` is a `"incoming-"` / `"sent-"` discriminator +/// so the same identity pair can appear in both lists without `ForEach` +/// collisions. +struct DashPayContactRequest: Identifiable { + let id: String + let senderId: Data + let recipientId: Data + let createdAt: Date + let senderDisplayName: String? + + init( + id: String, + senderId: Data, + recipientId: Data, + createdAt: Date = Date(), + senderDisplayName: String? = nil + ) { + self.id = id + self.senderId = senderId + self.recipientId = recipientId + self.createdAt = createdAt + self.senderDisplayName = senderDisplayName + } +} + // MARK: - Contact Row View struct ContactRowView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift deleted file mode 100644 index f1dc87b8f2f..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsViewStubs.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -// MARK: - Stub types for FriendsView -// These types are placeholders - the actual DashPay contact system needs to be implemented - -public struct DashPayContact: Identifiable { - public let id: Data - public let displayName: String - public let identityId: Data - public let dpnsName: String? - public let note: String? - public let isHidden: Bool - - public init(id: Data, displayName: String, identityId: Data, dpnsName: String? = nil, note: String? = nil, isHidden: Bool = false) { - self.id = id - self.displayName = displayName - self.identityId = identityId - self.dpnsName = dpnsName - self.note = note - self.isHidden = isHidden - } -} - -public struct DashPayContactRequest: Identifiable { - public let id: String - public let senderId: Data - public let recipientId: Data - public let createdAt: Date - public let senderDisplayName: String? - - public init(id: String, senderId: Data, recipientId: Data, createdAt: Date = Date(), senderDisplayName: String? = nil) { - self.id = id - self.senderId = senderId - self.recipientId = recipientId - self.createdAt = createdAt - self.senderDisplayName = senderDisplayName - } -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift deleted file mode 100644 index 3bc2cb3c1c2..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ObservableDashPayService.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import SwiftUI -import SwiftDashSDK - -// MARK: - Observable wrapper for DashPayService -// The SDK's DashPayService is Sendable but not ObservableObject. -// This wrapper provides ObservableObject conformance for SwiftUI. - -@MainActor -public final class ObservableDashPayService: ObservableObject { - private let service: DashPayService - - @Published public var isLoading = false - @Published public var error: Error? - - public init() { - self.service = DashPayService() - } - - // TODO: Implement actual DashPay functionality by wrapping service methods - // For now this is a stub that allows the app to compile -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index da655489643..2213d30670e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1364,7 +1364,10 @@ struct AccountStorageDetailView: View { label: "Platform Addresses", value: "\(record.platformAddresses.count)" ) - FieldRow(label: "Wallet", value: record.wallet.name ?? hexString(record.wallet.walletId)) + FieldRow( + label: "Wallet", + value: record.wallet.name ?? hexString(record.wallet.walletId) + ) } ForEach(addressSections(), id: \.0) { poolName, addresses in Section("\(poolName) Addresses (\(addresses.count))") { From ea4c16a08fdbca051489d2a543940e610f4c037c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 18:30:15 +0200 Subject: [PATCH 2/8] fix(swift-sdk): address PR #3765 review feedback - WalletKeyHealthSheet: branch HASH160 keys against pubkey-hash hex instead of comparing the 33-byte derived pubkey to the 20-byte stored hash; report unsupported key types as orphan with a clear reason. Rederive returns per-key failures so the sheet can show WHICH key is stuck. deleteOrphan also wipes the identity's Keychain entries. - KeychainManager: deleteIdentityPrivateKey now takes walletId (symmetric with store) and also sweeps the legacy account. Added deleteAllIdentityPrivateKeys(forIdentityIdBase58:). - AddIdentityKeyView: system contract entries always win over user-saved rows that share an ID, so DashPay's bounded document- type metadata isn't masked by a parallel saved-contract entry. - CreateIdentityView: shouldRegisterDashPayKeys computed gate mirrors the UI section visibility; submit() and the funding-min calc both route through it. KeyValidation.validatePrivateKeyForPublicKey added to makeDashpayKeyPair before persisting. - PlatformWalletManager.deleteWallet: keychain cleanup moved BEFORE deleteWalletData so a partial-failure retry can still find the identityIds to purge. - StorageRecordDetailViews: AccountStorageDetailView now uses the walletLabel(record.wallet) helper. - SDK.swift: platform_version=11 pin has an explicit TODO + trigger describing when to bump it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlatformWalletManager.swift | 21 ++- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 8 ++ .../Security/KeychainManager.swift | 89 ++++++++++++- .../Core/Views/WalletKeyHealthSheet.swift | 126 +++++++++++++++--- .../Views/AddIdentityKeyView.swift | 36 +++-- .../Views/CreateIdentityView.swift | 40 +++++- .../Views/StorageRecordDetailViews.swift | 2 +- 7 files changed, 273 insertions(+), 49 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 1160ec47402..e26f0865133 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -544,6 +544,22 @@ public class PlatformWalletManager: ObservableObject { let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId) + // Wipe Keychain BEFORE the SwiftData identity deletion runs. + // Order matters for retry-safety: if `deleteWalletData` + // commits identity rows and then throws partway, a retry + // would see `identityIdsForWallet == []` and the + // `deleteAllKeychainItems(forIdentityId:)` sweep below + // could no longer find the keys to purge. Doing the + // keychain side first leaves at worst stale SwiftData + // rows on a retry — repeating the wipe is harmless, and + // every keychain call here is idempotent (no-op on "not + // found"). Mnemonic / metadata stay in `WalletStorage` + // for now so a retry can still derive any missed key. + for identityId in identityIds { + try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) + } + try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) + try walletId.withUnsafeBytes { raw in guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else { throw PlatformWalletError.nullPointer( @@ -557,11 +573,6 @@ public class PlatformWalletManager: ObservableObject { try persistenceHandler.deleteWalletData(walletId: walletId) - for identityId in identityIds { - try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) - } - try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) - let storage = WalletStorage() // Delete metadata first so the mnemonic remains available for retry. try storage.deleteMetadata(for: walletId) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 866c02436e0..29b0eefd736 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -255,6 +255,14 @@ public final class SDK: @unchecked Sendable { } else { switch network { case .mainnet, .testnet: + // TODO(platform-version-bump): bump mainnet/testnet to 12 + // (or whatever PV is current) once drive-abci 3.1+ has + // rolled out on those networks and the new + // `getDocuments` V1 wire format is on by default. The + // trigger is: a HardFork shipping the V1 wire format + // becomes active on mainnet/testnet. Until then, pinning + // 11 keeps the SDK speaking the V0 protocol the active + // tenderdash quorums understand. resolvedPlatformVersion = 11 case .devnet, .regtest: resolvedPlatformVersion = 12 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index c402ac40994..fa32fd41ca7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -789,21 +789,96 @@ extension KeychainManager { return false } - /// Delete the identity private-key row for `derivationPath`. - /// Idempotent; returns true on success or "not found". + /// Delete the identity private-key row for the + /// `(walletId, derivationPath)` pair — symmetric with + /// `storeIdentityPrivateKey` (which writes under the + /// `identity_privkey..` account scheme). + /// + /// Idempotent; returns true on success or "not found". A + /// best-effort sweep of the legacy + /// (`identity_privkey.` — no walletId) account is + /// included so callers don't end up with a half-migrated state + /// where the new-format row is gone but a legacy row at the + /// same path lingers. @discardableResult - public nonisolated func deleteIdentityPrivateKey(derivationPath: String) -> Bool { - let account = "identity_privkey.\(derivationPath)" + public nonisolated func deleteIdentityPrivateKey( + walletId: Data, + derivationPath: String + ) -> Bool { + let walletIdHex = walletId.toHexString() + let newAccount = "identity_privkey.\(walletIdHex).\(derivationPath)" + let legacyAccount = "identity_privkey.\(derivationPath)" + var ok = true + for account in [newAccount, legacyAccount] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + ] + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + ok = false + } + } + return ok + } + + /// Delete every `identity_privkey.*` keychain row whose + /// `IdentityPrivateKeyMetadata.identityId` matches + /// `identityIdBase58` — the base58 identity id Swift uses + /// throughout the persistence layer. Used by the key-health + /// sheet's "Delete orphan identity" action so cascading the + /// SwiftData row doesn't leave its keys' private bytes behind + /// in Keychain. + /// + /// Scans the metadata blob (`kSecAttrGeneric`) on every + /// matching keychain item — handles both new-format + /// (`identity_privkey..`) and legacy-format + /// (`identity_privkey.`) accounts uniformly. Idempotent; + /// no-op when nothing matches. + public nonisolated func deleteAllIdentityPrivateKeys(forIdentityIdBase58 identityIdBase58: String) throws { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, - kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, ] if let accessGroup = accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return + } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + throw KeychainError.retrieveFailed(status) + } + + let decoder = JSONDecoder() + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("identity_privkey.") + else { + continue + } + guard let metadataData = item[kSecAttrGeneric as String] as? Data, + let metadata = try? decoder.decode( + IdentityPrivateKeyMetadata.self, + from: metadataData + ) + else { + continue + } + guard metadata.identityId == identityIdBase58 else { + continue + } + try deleteGenericPassword(account: account) + } } /// Delete every `identity_privkey.` keychain row whose diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift index 53d4ae5da2b..2e5d210e0c7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -189,9 +189,35 @@ enum WalletKeyHealthChecker { keyId: kid, network: network ) - derivedHex = preview.publicKeyHex - if preview.publicKeyData == row.publicKeyData { + // `row.publicKeyData` stores whatever shape was + // registered on Platform — 33-byte compressed + // pubkey for `.ecdsaSecp256k1`, 20-byte HASH160 + // for `.ecdsaHash160`. The Rust-derived preview + // is always the raw 33-byte pubkey; hash it + // ourselves before comparing for HASH160 rows. + // (Other variants — BLS, BIP13 script-hash, + // EdDSA — aren't produced by this preview path + // today; treat them as `notSupported` so the + // diagnostic surfaces them instead of silently + // misclassifying them as orphans.) + let derivedComparableHex: String? + switch row.keyTypeEnum ?? .ecdsaSecp256k1 { + case .ecdsaSecp256k1: + derivedComparableHex = preview.publicKeyHex + derivedHex = preview.publicKeyHex + case .ecdsaHash160: + let hashHex = SwiftDashSDK.KeychainManager + .computePublicKeyHashHex(preview.publicKeyData) + derivedComparableHex = hashHex.isEmpty ? nil : hashHex + derivedHex = hashHex + case .bls12_381, .bip13ScriptHash, .eddsa25519Hash160: + derivedComparableHex = nil + derivedHex = preview.publicKeyHex + } + + if let derivedComparableHex, + derivedComparableHex.caseInsensitiveCompare(storedHex) == .orderedSame { // pubkey matches the wallet's mnemonic → // look up the keychain bytes at the expected // walletId-namespaced account. @@ -224,9 +250,20 @@ enum WalletKeyHealthChecker { reason: "No new-format Keychain entry (legacy-only entries don't count)" ) } + } else if derivedComparableHex == nil { + // Key type isn't one the diagnostic knows + // how to compare against a Rust-derived + // pubkey today (BLS / BIP13 / EdDSA). Report + // it as orphan with a clear reason so the + // user knows we can't verify it, rather than + // silently passing. + let label = row.keyTypeEnum?.name ?? "type \(row.keyType)" + status = .orphan( + reason: "Key type \(label) isn't supported by the diagnostic — can't verify against derived pubkey" + ) } else { status = .orphan( - reason: "Stored pubkey \(storedHex.prefix(12))… doesn't match wallet's derivation \(derivedHex.prefix(12))…" + reason: "Stored \(storedHex.prefix(12))… doesn't match wallet's derivation \(derivedHex.prefix(12))…" ) } } catch { @@ -262,11 +299,30 @@ enum WalletKeyHealthChecker { return reports } + /// Per-key result of a rederive pass. `success` is the count of + /// keys whose Keychain entry was rewritten; `failures` lists each + /// key the pass tried and couldn't fix, with a reason — useful so + /// the sheet can show the user *which* key is stuck (not just + /// "Re-derived 0 keys"). + struct RederiveOutcome { + let success: Int + /// `(keyId, reason)` for each `.needsRederive` key the loop + /// touched but did not fix. + let failures: [(UInt32, String)] + } + /// Re-derive every key in `report` whose status is /// `.needsRederive`, write fresh Keychain entries (at the new /// walletId-namespaced account), and update each /// `PersistentPublicKey.privateKeyKeychainIdentifier` to point - /// at the new account. Returns the number of keys fixed. + /// at the new account. + /// + /// Returns a `RederiveOutcome` with both the count fixed and a + /// per-key list of failures. Individual key failures are + /// collected rather than thrown so one bad key doesn't block + /// repair of the rest of the identity's keys; throws only when + /// the whole batch is unrecoverable (e.g. the SwiftData save at + /// the end fails). @MainActor static func rederive( report: WalletIdentityKeyHealthReport, @@ -274,15 +330,22 @@ enum WalletKeyHealthChecker { walletId: Data, network: Network, modelContext: ModelContext - ) throws -> Int { + ) throws -> RederiveOutcome { var fixed = 0 + var failures: [(UInt32, String)] = [] for key in report.keys { guard case .needsRederive = key.status else { continue } - let preview = try wallet.deriveIdentityAuthKeyAtSlot( - identityIndex: report.identityIndex, - keyId: key.keyId, - network: network - ) + let preview: ManagedPlatformWallet.IdentityRegistrationKeyPreview + do { + preview = try wallet.deriveIdentityAuthKeyAtSlot( + identityIndex: report.identityIndex, + keyId: key.keyId, + network: network + ) + } catch { + failures.append((key.keyId, "derivation failed: \(error.localizedDescription)")) + continue + } let pubkeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex(preview.publicKeyData) let metadata = IdentityPrivateKeyMetadata( identityId: report.identityIdBase58, @@ -302,6 +365,7 @@ enum WalletKeyHealthChecker { derivationPath: preview.derivationPath, metadata: metadata ) else { + failures.append((key.keyId, "Keychain write at \(preview.derivationPath) returned nil")) continue } key.row.privateKeyKeychainIdentifier = pkid @@ -321,22 +385,36 @@ enum WalletKeyHealthChecker { if fixed > 0 { try modelContext.save() } - return fixed + return RederiveOutcome(success: fixed, failures: failures) } - /// Cascade-delete an orphan identity from SwiftData. Safe to - /// call now that the relationship inverses - /// (`PersistentPublicKey.identity`, `PersistentDPNSName.identity`, - /// `PersistentDashpayProfile.identity`, `PersistentDashpayContactRequest.owner`) - /// are all Optional — see the doc comments on those models for - /// the SwiftData cascade-on-non-optional crash this avoids. + /// Cascade-delete an orphan identity from SwiftData AND wipe its + /// associated Keychain entries. Order matters: clear the + /// Keychain side first (purely additive to the SwiftData state), + /// then drop the row. If Keychain wipe fails we still try the + /// SwiftData delete so the user can finish the operation; the + /// keychain error is surfaced to the caller. @MainActor static func deleteOrphan( identity: PersistentIdentity, modelContext: ModelContext ) throws { + // Snapshot the base58 id BEFORE the delete — once the row + // is removed its computed accessor is invalid. + let identityIdBase58 = identity.identityIdBase58 + var keychainError: Error? + do { + try KeychainManager.shared.deleteAllIdentityPrivateKeys( + forIdentityIdBase58: identityIdBase58 + ) + } catch { + keychainError = error + } modelContext.delete(identity) try modelContext.save() + if let keychainError { + throw keychainError + } } } @@ -550,15 +628,23 @@ struct WalletKeyHealthSheet: View { private func rederive(_ report: WalletIdentityKeyHealthReport) { Task { @MainActor in do { - let fixed = try WalletKeyHealthChecker.rederive( + let outcome = try WalletKeyHealthChecker.rederive( report: report, wallet: wallet, walletId: walletId, network: network, modelContext: modelContext ) - actionMessage = "Re-derived \(fixed) key\(fixed == 1 ? "" : "s") for identity \(report.identityIdBase58.prefix(12))…" - errorMessage = nil + let fixed = outcome.success + var msg = "Re-derived \(fixed) key\(fixed == 1 ? "" : "s") for identity \(report.identityIdBase58.prefix(12))…" + if !outcome.failures.isEmpty { + let detail = outcome.failures + .map { "kid \($0.0): \($0.1)" } + .joined(separator: "; ") + msg += " — \(outcome.failures.count) failed: \(detail)" + } + actionMessage = msg + errorMessage = outcome.failures.isEmpty ? nil : msg // Re-run the check so the report reflects the new // state (formerly-orange rows should turn green). await runCheck() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift index 086fa0a849b..cbe82829b0a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddIdentityKeyView.swift @@ -138,25 +138,33 @@ struct AddIdentityKeyView: View { /// validation error. Tightening this would require parsing /// the per-document-type schema flag into SwiftData rows. private var pickerEntries: [BoundsPickerEntry] { - let savedIds = Set(contractsForNetwork.map(\.id)) - let system = Self.systemContractsAllowingKeyBounds - .filter { !savedIds.contains($0.id) } + // System metadata wins over user-saved rows: the system + // registry knows precisely which document types within the + // contract carry the bounded-key requirement (DashPay's + // `contactRequest`, for example, vs the rest of the + // contract). If we let a saved row override the system + // entry — even when the IDs match — the picker would fall + // back to the "expose every document type" behaviour and + // an invalid (contract, doc-type) combo could slip through. + let systemIds = Set(Self.systemContractsAllowingKeyBounds.map(\.id)) + let system = Self.systemContractsAllowingKeyBounds.map { + BoundsPickerEntry( + id: $0.id, + displayName: "\($0.name) (System)", + allowsContractScope: $0.allowsContractScope, + documentTypesAllowingBounds: $0.documentTypesAllowingBounds + ) + } + let saved = contractsForNetwork + .filter { !systemIds.contains($0.id) } .map { BoundsPickerEntry( id: $0.id, - displayName: "\($0.name) (System)", - allowsContractScope: $0.allowsContractScope, - documentTypesAllowingBounds: $0.documentTypesAllowingBounds + displayName: $0.name, + allowsContractScope: true, + documentTypesAllowingBounds: ($0.documentTypes ?? []).map(\.name).sorted() ) } - let saved = contractsForNetwork.map { - BoundsPickerEntry( - id: $0.id, - displayName: $0.name, - allowsContractScope: true, - documentTypesAllowingBounds: ($0.documentTypes ?? []).map(\.name).sorted() - ) - } return system + saved } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index 394cf51e58f..045534a5d77 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -110,11 +110,26 @@ struct CreateIdentityView: View { return (base + assetLockBaseCredits + perKey) / 1_000 } + /// Whether the DashPay-keys toggle ACTUALLY applies to the + /// active submission, mirroring `dashpayKeysSection`'s + /// visibility predicate. The Toggle UI is hidden for resume / + /// walletless flows (those paths don't pre-derive at custom + /// indices today), so an `@State` `addDashPayKeys = true` value + /// would still be true when the user is in those flows. Routing + /// every call site through this gate keeps the funding-minimum + /// calculation and the actual `makeDashpayKeyPair` invocation + /// in lock-step with what the user sees. + private var shouldRegisterDashPayKeys: Bool { + guard case .wallet = walletSelection else { return false } + guard fundingSelection != .unusedAssetLock else { return false } + return addDashPayKeys + } + /// Total identity keys this submission will register, given the /// current DashPay-keys toggle. Drives the asset-lock minimum /// + the on-screen min-funding hint. private var plannedIdentityKeyCount: UInt32 { - Self.defaultKeyCount + (addDashPayKeys ? Self.dashPayExtraKeyCount : 0) + Self.defaultKeyCount + (shouldRegisterDashPayKeys ? Self.dashPayExtraKeyCount : 0) } /// Per-submission minimum funding in duffs — scales with @@ -859,7 +874,7 @@ struct CreateIdentityView: View { // walletId-namespaced keychain account so the trampoline // can find them when DashPay flows ask the new keys to // sign. - if addDashPayKeys { + if shouldRegisterDashPayKeys { do { identityPubkeys.append(contentsOf: try makeDashpayKeyPair( managedWallet: managedWallet, @@ -1409,6 +1424,27 @@ struct CreateIdentityView: View { keyId: keyId, network: network ) + + // Defence against derivation drift / FFI marshalling + // bugs — mirrors `AddIdentityKeyView.submit`'s cross- + // check. A mismatched DashPay key lands on Platform as + // a key the trampoline can't sign with and surfaces as + // an opaque "encrypted xpub" failure on the first + // contact-request flow, which is much harder to debug + // after the fact than failing fast here. + guard + KeyValidation.validatePrivateKeyForPublicKey( + privateKeyHex: preview.privateKeyData.toHexString(), + publicKeyHex: preview.publicKeyHex, + keyType: .ecdsaSecp256k1, + network: network + ) + else { + throw PlatformWalletError.walletOperation( + "Derived DashPay key (kid \(keyId), purpose \(purpose.name)) didn't match its public key — refusing to persist" + ) + } + let pubKeyHashHex = SwiftDashSDK.KeychainManager.computePublicKeyHashHex( preview.publicKeyData ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 2213d30670e..497d5a9a7ea 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1366,7 +1366,7 @@ struct AccountStorageDetailView: View { ) FieldRow( label: "Wallet", - value: record.wallet.name ?? hexString(record.wallet.walletId) + value: walletLabel(record.wallet) ) } ForEach(addressSections(), id: \.0) { poolName, addresses in From b83f9352e2f3688c78146f4e46cf0f6c9012eb55 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 18:33:22 +0200 Subject: [PATCH 3/8] fix(swift-sdk): four-phase deleteOrphan to avoid non-Optional inverse fatal `PersistentDPNSName.identity`, `PersistentDashpayProfile.identity`, and `PersistentDashpayContactRequest.owner` are non-Optional, which makes a single-save `modelContext.delete(identity)` fatal whenever SwiftData has to null out their inverses in the same save batch. `WalletKeyHealthChecker.deleteOrphan` now mirrors `PlatformWalletPersistenceHandler.deleteWalletData`'s per-layer approach: pre-delete DPNS names + DashPay profile + contact requests + save, then delete the identity + save. Updated the stale doc comment that claimed the inverses are Optional. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/WalletKeyHealthSheet.swift | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift index 2e5d210e0c7..03ceabc01bd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -389,11 +389,24 @@ enum WalletKeyHealthChecker { } /// Cascade-delete an orphan identity from SwiftData AND wipe its - /// associated Keychain entries. Order matters: clear the - /// Keychain side first (purely additive to the SwiftData state), - /// then drop the row. If Keychain wipe fails we still try the - /// SwiftData delete so the user can finish the operation; the - /// keychain error is surfaced to the caller. + /// associated Keychain entries. + /// + /// Ordering: + /// 1. Wipe Keychain first (`identity_privkey.*` rows whose + /// metadata matches this identity). Idempotent on retry. + /// 2. Pre-delete the identity's cascade children that have + /// non-Optional inverses back to it — DPNS names, DashPay + /// profile, contact requests — and save. SwiftData fatals + /// on `save()` whenever it tries to null out a non- + /// Optional inverse, so the parent delete in step 3 cannot + /// be in the same save batch as these children; mirrors + /// the per-layer save pattern in + /// `PlatformWalletPersistenceHandler.deleteWalletData`. + /// 3. Delete the identity itself + save. + /// + /// If the keychain wipe throws we still attempt the SwiftData + /// delete so the user can finish the operation; the keychain + /// error is surfaced to the caller after the deletes complete. @MainActor static func deleteOrphan( identity: PersistentIdentity, @@ -410,8 +423,31 @@ enum WalletKeyHealthChecker { } catch { keychainError = error } + + // PHASE 1: delete cascade children with non-Optional + // inverses to identity. Same reasoning as + // `PlatformWalletPersistenceHandler.deleteWalletData` — + // PersistentPublicKey / Document / TokenBalance inverses + // to identity are already Optional and don't need pre- + // deletion; DPNS names, DashPay profile, contact requests + // do. + for name in Array(identity.dpnsNames) { + modelContext.delete(name) + } + if let profile = identity.dashpayProfile { + modelContext.delete(profile) + } + for cr in Array(identity.contactRequests) { + modelContext.delete(cr) + } + try modelContext.save() + + // PHASE 2: delete the identity itself. Its problematic + // cascade children are gone, so SwiftData has no non- + // Optional inverse to null out during the merge phase. modelContext.delete(identity) try modelContext.save() + if let keychainError { throw keychainError } From 68f98ad1d1890e5e8db5366fe8962b700fc0d3d1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 18:49:19 +0200 Subject: [PATCH 4/8] fix(platform-wallet-ffi,swift-sdk): carry ContractBounds through identity-key persister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PersistentPublicKey` was serializing ContractBounds as a bare `[contractId]` list and reconstructing them as `.singleContract(id:)`, silently dropping the `documentTypeName` qualifier. DashPay's encryption/decryption keys register with `.singleContractDocumentType(id: dashpay, documentTypeName: "contactRequest")`; after one SwiftData round-trip the local DPP projection on `identity.identityPublicKeys` was a weaker `.singleContract` bound that didn't match the on-chain key. Extended the FFI struct + Swift persistence shape to round-trip both ContractBounds variants verbatim: Rust (rs-platform-wallet-ffi): - IdentityKeyEntryFFI gains contract_bounds_{kind, id, document_type}. Kind tag mirrors DPP's `ContractBounds` (0=none, 1=SingleContract, 2=SingleContractDocumentType); doc-type is a heap CString released via free_identity_key_entry_ffi. - Layout assert bumped (136 → 184 bytes). - 2 new tests covering both bound variants + idempotent free. Swift (swift-sdk): - IdentityKeyEntrySnapshot carries ManagedPlatformWallet.ContractBounds. - persistIdentityKeysCallback projects the FFI trio into the enum. - persistIdentityKeys writes both columns on insert AND update. - PersistentPublicKey grows a contractBoundsDocumentTypeName column; toIdentityPublicKey()/from(IdentityPublicKey) round-trip both variants. Legacy stores without the new column load cleanly (per repo policy — dev stores rebuild on schema changes). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/identity_persistence.rs | 144 +++++++++++++++++- .../Models/PersistentPublicKey.swift | 61 +++++++- .../PlatformWalletPersistenceHandler.swift | 73 ++++++++- 3 files changed, 267 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index a4296824b28..c9c0d5f7ae5 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -188,6 +188,27 @@ pub struct IdentityKeyEntryFFI { pub derivation_indices_is_some: bool, pub identity_index: u32, pub key_index: u32, + + // ContractBounds projection. Mirrors the DPP enum + // `ContractBounds` so the client can reconstruct the variant + // verbatim instead of dropping the document-type name: + // + // * `contract_bounds_kind == 0` — no contract bounds; the + // `id` field is zeroed and the doc-type pointer is null. + // * `contract_bounds_kind == 1` — `SingleContract`; only the + // 32-byte `id` is meaningful, doc-type pointer is null. + // * `contract_bounds_kind == 2` — `SingleContractDocumentType`; + // both the `id` and the heap-allocated UTF-8 doc-type + // C-string are meaningful. Doc-type string is released by + // [`free_identity_key_entry_ffi`]. + // + // Keeping the kind tag inline (vs. always nulling fields) lets + // the Swift side switch on a single discriminant without + // probing pointer values, matching how the rest of this struct + // encodes optional payloads. + pub contract_bounds_kind: u8, + pub contract_bounds_id: [u8; 32], + pub contract_bounds_document_type: *const c_char, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -228,9 +249,13 @@ pub struct IdentityKeyRemovalFFI { // 126..=127 (padding to 4) // 128..=131 identity_index u32 // 132..=135 key_index u32 +// 136 contract_bounds_kind u8 +// 137..=168 contract_bounds_id [u8; 32] +// 169..=175 (padding to 8 for pointer alignment) +// 176..=183 contract_bounds_document_type *const c_char // -// Total size = 136, alignment = 8 (from u64 / pointer). -const _: [u8; 136] = [0u8; std::mem::size_of::()]; +// Total size = 184, alignment = 8 (from u64 / pointer). +const _: [u8; 184] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -451,10 +476,12 @@ fn allocate_dpns_arrays( impl IdentityKeyEntryFFI { /// Copy an [`IdentityKeyEntry`] into a fresh FFI struct. The /// caller owns the heap-allocated `public_key_data_ptr` byte - /// buffer and (when present) the `private_key_derivation_path` - /// C-string; release both via [`free_identity_key_entry_ffi`]. + /// buffer and (when present) the + /// `contract_bounds_document_type` C-string; release both via + /// [`free_identity_key_entry_ffi`]. pub fn from_entry(entry: &IdentityKeyEntry) -> Self { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; let pk_bytes = entry.public_key.data().as_slice().to_vec(); let pk_len = pk_bytes.len(); @@ -477,6 +504,26 @@ impl IdentityKeyEntryFFI { None => (false, 0, 0), }; + // Project the DPP `ContractBounds` enum into the kind / + // id / doc-type-cstring trio so the Swift side can switch + // on a single discriminant. Strings containing interior + // NULs (impossible in practice — DPP rejects them) fall + // back to a null pointer, leaving the kind tag set to 2; + // Swift treats null doc-type-with-kind-2 as "no bounds" + // rather than constructing a half-formed variant. + let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = + match entry.public_key.contract_bounds() { + Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), + Some(ContractBounds::SingleContractDocumentType { id, document_type_name }) => { + let doc_type_ptr = match CString::new(document_type_name.as_str()) { + Ok(c) => c.into_raw() as *const c_char, + Err(_) => ptr::null(), + }; + (2u8, id.to_buffer(), doc_type_ptr) + } + None => (0u8, [0u8; 32], ptr::null()), + }; + Self { identity_id: entry.identity_id.to_buffer(), key_id: entry.key_id, @@ -494,6 +541,9 @@ impl IdentityKeyEntryFFI { derivation_indices_is_some, identity_index, key_index, + contract_bounds_kind, + contract_bounds_id, + contract_bounds_document_type, } } } @@ -601,8 +651,12 @@ unsafe fn free_optional_c_string(slot: &mut *const c_char) { } /// Release heap allocations owned by an [`IdentityKeyEntryFFI`] — -/// the public-key data buffer and, when present, the derivation-path -/// string for the `AtWalletDerivationPath` variant. +/// the public-key data buffer and, when present, the contract-bounds +/// document-type C-string (set when +/// `contract_bounds_kind == 2`, i.e. SingleContractDocumentType). +/// +/// Idempotent: pointers are nulled and length zeroed after release, +/// so a second call is a no-op. /// /// # Safety /// @@ -621,6 +675,15 @@ pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { entry.public_key_data_ptr = ptr::null_mut(); entry.public_key_data_len = 0; } + // Release the contract-bounds doc-type C-string. Only allocated + // when the original entry carried `SingleContractDocumentType` + // bounds (and the doc-type name didn't contain interior NULs). + if !entry.contract_bounds_document_type.is_null() { + let _ = unsafe { + CString::from_raw(entry.contract_bounds_document_type as *mut c_char) + }; + entry.contract_bounds_document_type = ptr::null(); + } // No private-key heap allocations to reclaim — the new FFI shape // carries only scalar derivation breadcrumbs, not an owned path // string or key-material buffer. @@ -878,6 +941,75 @@ mod tests { assert!(ffi.read_only); assert!(ffi.disabled_at_is_some); assert_eq!(ffi.disabled_at, 1_700_000_000); + assert_eq!(ffi.contract_bounds_kind, 0); + assert!(ffi.contract_bounds_document_type.is_null()); + unsafe { free_identity_key_entry_ffi(&mut ffi) }; + } + + #[test] + fn test_identity_key_entry_ffi_contract_bounds_single_contract() { + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let contract_id = Identifier::from([0xAB; 32]); + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::SingleContract { id: contract_id }), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0x01; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id: Identifier::from([1u8; 32]), + key_id: 1, + public_key, + public_key_hash: [0x11; 20], + wallet_id: None, + derivation_indices: None, + }; + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + assert_eq!(ffi.contract_bounds_kind, 1); + assert_eq!(ffi.contract_bounds_id, [0xAB; 32]); + assert!(ffi.contract_bounds_document_type.is_null()); + unsafe { free_identity_key_entry_ffi(&mut ffi) }; + } + + #[test] + fn test_identity_key_entry_ffi_contract_bounds_single_doc_type() { + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let contract_id = Identifier::from([0xCD; 32]); + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: "contactRequest".to_string(), + }), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id: Identifier::from([4u8; 32]), + key_id: 2, + public_key, + public_key_hash: [0x22; 20], + wallet_id: None, + derivation_indices: None, + }; + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + assert_eq!(ffi.contract_bounds_kind, 2); + assert_eq!(ffi.contract_bounds_id, [0xCD; 32]); + assert!(!ffi.contract_bounds_document_type.is_null()); + // Verify the doc-type CString round-trips. + let cstr = unsafe { std::ffi::CStr::from_ptr(ffi.contract_bounds_document_type) }; + assert_eq!(cstr.to_str().unwrap(), "contactRequest"); + unsafe { free_identity_key_entry_ffi(&mut ffi) }; + // Idempotent free. + assert!(ffi.contract_bounds_document_type.is_null()); unsafe { free_identity_key_entry_ffi(&mut ffi) }; } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index 2c90932e2f5..9372785adb3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -16,8 +16,24 @@ public final class PersistentPublicKey { public var publicKeyData: Data // MARK: - Contract Bounds + /// JSON-encoded `[base64(contractId)]` — legacy storage shape + /// that only retains the contract id, never the document-type + /// name. New code paths still write here for the id portion; + /// `contractBoundsDocumentTypeName` carries the doc-type so + /// the `SingleContractDocumentType` variant round-trips + /// faithfully. Keeping the field shape lets old SwiftData + /// stores that predate the doc-type column continue to load + /// without migration (the doc-type column is just `nil`). public var contractBoundsData: Data? + /// When set, the key's bounds are + /// `.singleContractDocumentType(id: contractBoundsData[0], + /// documentTypeName: contractBoundsDocumentTypeName)`. When + /// `nil`, the key is either unbounded (when `contractBoundsData` + /// is also nil) or bounded to a whole contract via + /// `.singleContract(id:)`. Optional so old stores load cleanly. + public var contractBoundsDocumentTypeName: String? + // MARK: - Private Key Reference (optional) public var privateKeyKeychainIdentifier: String? @@ -40,6 +56,7 @@ public final class PersistentPublicKey { readOnly: Bool = false, disabledAt: Int64? = nil, contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, identityId: String ) { self.keyId = keyId @@ -54,6 +71,7 @@ public final class PersistentPublicKey { } else { self.contractBoundsData = nil } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName self.identityId = identityId self.createdAt = Date() } @@ -105,7 +123,16 @@ public final class PersistentPublicKey { // MARK: - Conversion Extensions extension PersistentPublicKey { - /// Convert to IdentityPublicKey + /// Convert to IdentityPublicKey. + /// + /// Reconstructs the `ContractBounds` variant from the two + /// persisted columns: when `contractBoundsDocumentTypeName` is + /// set, we hand back `.singleContractDocumentType` so the + /// document-type qualifier survives a SwiftData round-trip + /// (without this, the DashPay encryption/decryption keys' + /// `contactRequest` scope would silently weaken to a whole- + /// contract `.singleContract` bound). When only the legacy + /// `contractBoundsData` is set, fall back to `.singleContract`. public func toIdentityPublicKey() -> IdentityPublicKey? { guard let purpose = purposeEnum, let securityLevel = securityLevelEnum, @@ -113,11 +140,22 @@ extension PersistentPublicKey { return nil } + let bounds: ContractBounds? + if let id = contractBounds?.first { + if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + return IdentityPublicKey( id: KeyID(keyId), purpose: purpose, securityLevel: securityLevel, - contractBounds: contractBounds?.first.map { .singleContract(id: $0) }, + contractBounds: bounds, keyType: keyType, readOnly: readOnly, data: publicKeyData, @@ -125,8 +163,22 @@ extension PersistentPublicKey { ) } - /// Create from IdentityPublicKey + /// Create from IdentityPublicKey, preserving both + /// `ContractBounds` variants (id + optional document-type name). public static func from(_ publicKey: IdentityPublicKey, identityId: String) -> PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + case .none: + boundsIds = nil + docTypeName = nil + } return PersistentPublicKey( keyId: Int32(publicKey.id), purpose: publicKey.purpose, @@ -135,7 +187,8 @@ extension PersistentPublicKey { publicKeyData: publicKey.data, readOnly: publicKey.readOnly, disabledAt: publicKey.disabledAt.map { Int64($0) }, - contractBounds: publicKey.contractBounds != nil ? [publicKey.contractBounds!.contractId] : nil, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, identityId: identityId ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 3eef00b5baf..718124f8373 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1347,6 +1347,29 @@ public class PlatformWalletPersistenceHandler { $0.keyId == targetKeyId && $0.identityId == identityHex } ) + // Project the snapshot's ContractBounds enum into the + // pair of columns `PersistentPublicKey` uses: + // * `contractBoundsIds` — `[contractId]` (or nil) + // * `contractBoundsDocumentTypeName` — non-nil iff the + // bound was `.singleContractDocumentType` + // Keeping both lets the SwiftData row round-trip both + // variants verbatim; legacy stores without the + // doc-type column just see `nil` for the second field + // and reconstruct as `.singleContract`. + let snapshotBoundsIds: [Data]? + let snapshotBoundsDocType: String? + switch entry.contractBounds { + case .some(.singleContract(let id)): + snapshotBoundsIds = [id] + snapshotBoundsDocType = nil + case .some(.singleContractDocumentType(let id, let name)): + snapshotBoundsIds = [id] + snapshotBoundsDocType = name + case .none: + snapshotBoundsIds = nil + snapshotBoundsDocType = nil + } + let row: PersistentPublicKey if let existing = try? backgroundContext.fetch(descriptor).first { row = existing @@ -1362,6 +1385,8 @@ public class PlatformWalletPersistenceHandler { publicKeyData: entry.publicKeyData, readOnly: entry.readOnly, disabledAt: entry.disabledAt.map { Int64(bitPattern: $0) }, + contractBounds: snapshotBoundsIds, + contractBoundsDocumentTypeName: snapshotBoundsDocType, identityId: identityHex ) backgroundContext.insert(row) @@ -1382,6 +1407,13 @@ public class PlatformWalletPersistenceHandler { row.publicKeyData = entry.publicKeyData row.readOnly = entry.readOnly row.disabledAt = entry.disabledAt.map { Int64(bitPattern: $0) } + // Mirror the contract-bounds projection onto an + // existing row too — Rust is the source of truth on + // each callback, so the snapshot's bounds (which can + // change if Drive ever re-emits a key with a different + // scope) must overwrite any stale value here. + row.contractBounds = snapshotBoundsIds + row.contractBoundsDocumentTypeName = snapshotBoundsDocType // Private-key handling. // @@ -1940,6 +1972,14 @@ public class PlatformWalletPersistenceHandler { /// DIP-9 `(identity_index, key_index)` pair. Present iff the /// client is expected to re-derive the private key locally. let derivationIndices: (identityIndex: UInt32, keyIndex: UInt32)? + /// Full ContractBounds projection mirrored from Rust: + /// `nil` when the key has no bounds; `.singleContract` for + /// kind=1; `.singleContractDocumentType` for kind=2. Carried + /// so the SwiftData row preserves the doc-type name on + /// round-trip (would otherwise be silently downgraded to + /// `.singleContract` and break local DPP projections that + /// read `identity.identityPublicKeys`). + let contractBounds: ManagedPlatformWallet.ContractBounds? } // MARK: - Watch-only Restore: Account Addresses @@ -4574,6 +4614,36 @@ private func persistIdentityKeysCallback( e.derivation_indices_is_some ? (e.identity_index, e.key_index) : nil + + // Project the contract-bounds trio (kind / id / doc- + // type C-string) into the Swift enum. Mirrors the Rust + // `IdentityKeyEntryFFI::from_entry` encoding — kinds: + // 0 → no bounds + // 1 → SingleContract { id } + // 2 → SingleContractDocumentType { id, doc_type_name } + // The doc-type C-string for kind=2 is owned by Rust and + // freed via `free_identity_key_entry_ffi` after this + // callback returns, so we copy it into a Swift String + // here. A null doc-type pointer with kind=2 is a + // serialization edge case (interior NUL); treat it as + // no-bounds rather than constructing a partial variant. + let bounds: ManagedPlatformWallet.ContractBounds? + switch e.contract_bounds_kind { + case 1: + bounds = .singleContract(id: dataFromTuple32(e.contract_bounds_id)) + case 2: + if let docPtr = e.contract_bounds_document_type { + bounds = .singleContractDocumentType( + id: dataFromTuple32(e.contract_bounds_id), + documentTypeName: String(cString: docPtr) + ) + } else { + bounds = nil + } + default: + bounds = nil + } + upserts.append(.init( identityId: identityId, keyId: e.key_id, @@ -4585,7 +4655,8 @@ private func persistIdentityKeysCallback( publicKeyData: pubKey, publicKeyHash: dataFromTuple20(e.public_key_hash), walletId: walletId, - derivationIndices: indices + derivationIndices: indices, + contractBounds: bounds )) } } From 7ae00b8a85531e8159f30574a1210e16bd3722ba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 18:52:58 +0200 Subject: [PATCH 5/8] fix(swift-sdk): guard legacy keychain sweep by metadata.walletId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deleteIdentityPrivateKey(walletId:derivationPath:)` was deleting `identity_privkey.` (the legacy, non-namespaced account) unconditionally. The legacy format collided across wallets — that is the bug we are fixing — so an unconditional delete could clobber a different wallet's row at the same DIP-9 path. Promoted `WalletKeyHealthSheet.cleanupLegacyKeychainEntry` (which already did the right thing — lookup metadata, decode, match walletId, delete) into a public `KeychainManager` helper `deleteLegacyKeychainEntryIfOwnedByWallet(walletIdHex:derivationPath:)`. Both `deleteIdentityPrivateKey` and the post-rederive cleanup in `WalletKeyHealthSheet` now route through it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Security/KeychainManager.swift | 120 +++++++++++++++--- .../Core/Views/WalletKeyHealthSheet.swift | 53 ++------ 2 files changed, 108 insertions(+), 65 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index fa32fd41ca7..b8b53563b48 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -795,11 +795,20 @@ extension KeychainManager { /// `identity_privkey..` account scheme). /// /// Idempotent; returns true on success or "not found". A - /// best-effort sweep of the legacy - /// (`identity_privkey.` — no walletId) account is - /// included so callers don't end up with a half-migrated state - /// where the new-format row is gone but a legacy row at the - /// same path lingers. + /// guarded sweep of the legacy (`identity_privkey.` — + /// no walletId) account is also included so callers don't end + /// up with a half-migrated state where the new-format row is + /// gone but a legacy row at the same path lingers. + /// + /// SAFETY: the legacy account format collided across wallets + /// (the very bug we are fixing by namespacing the new path), + /// so deleting a `identity_privkey.` row unconditionally + /// could wipe a DIFFERENT wallet's secret if the two wallets + /// happened to derive an identity at the same DIP-9 path. The + /// sweep therefore looks up the legacy row, decodes its + /// metadata blob, and only deletes when + /// `metadata.walletId == walletIdHex`. Pathological in + /// practice but cheap to defend against. @discardableResult public nonisolated func deleteIdentityPrivateKey( walletId: Data, @@ -807,25 +816,96 @@ extension KeychainManager { ) -> Bool { let walletIdHex = walletId.toHexString() let newAccount = "identity_privkey.\(walletIdHex).\(derivationPath)" - let legacyAccount = "identity_privkey.\(derivationPath)" - var ok = true - for account in [newAccount, legacyAccount] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: account, - ] - if let accessGroup = accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } - let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess && status != errSecItemNotFound { - ok = false - } + var newQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: newAccount, + ] + if let accessGroup = accessGroup { + newQuery[kSecAttrAccessGroup as String] = accessGroup + } + let newStatus = SecItemDelete(newQuery as CFDictionary) + var ok = newStatus == errSecSuccess || newStatus == errSecItemNotFound + + if !deleteLegacyKeychainEntryIfOwnedByWallet( + walletIdHex: walletIdHex, + derivationPath: derivationPath + ) { + // Legacy sweep had a non-recoverable error (something + // other than "not found" / "different wallet's row"). + // The new-format delete already happened, so the + // caller's primary intent landed — surface the legacy + // failure via the return value so anyone treating it + // as a strict success can react. + ok = false } return ok } + /// Best-effort delete of the legacy (no-walletId) keychain + /// entry for `derivationPath`, gated on its decoded + /// `IdentityPrivateKeyMetadata.walletId` matching `walletIdHex`. + /// No-op when the row doesn't exist or belongs to a different + /// wallet. Returns `false` only on a genuine Keychain error + /// (NOT for "not found" or "different wallet's data") so the + /// caller can distinguish "nothing to do" from "tried + failed". + /// + /// Shared by both `deleteIdentityPrivateKey` (per-key delete) + /// and `WalletKeyHealthSheet.cleanupLegacyKeychainEntry` + /// (post-rederive cleanup) so both call sites apply the same + /// safety guard. + public nonisolated func deleteLegacyKeychainEntryIfOwnedByWallet( + walletIdHex: String, + derivationPath: String + ) -> Bool { + let legacyAccount = "identity_privkey.\(derivationPath)" + var lookupQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: legacyAccount, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let accessGroup = accessGroup { + lookupQuery[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let lookupStatus = SecItemCopyMatching(lookupQuery as CFDictionary, &result) + if lookupStatus == errSecItemNotFound { + return true + } + guard lookupStatus == errSecSuccess, let attrs = result as? [String: Any] else { + return false + } + guard let metadataData = attrs[kSecAttrGeneric as String] as? Data, + let metadata = try? JSONDecoder().decode( + IdentityPrivateKeyMetadata.self, + from: metadataData + ) + else { + // Row exists but metadata is malformed / missing. Be + // conservative: refuse to delete data we can't prove + // we own. Treat as success — we successfully decided + // not to delete. + return true + } + guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { + return true + } + + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: legacyAccount, + ] + if let accessGroup = accessGroup { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + return deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound + } + /// Delete every `identity_privkey.*` keychain row whose /// `IdentityPrivateKeyMetadata.identityId` matches /// `identityIdBase58` — the base58 identity id Swift uses diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift index 03ceabc01bd..286a6fc3233 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -100,59 +100,22 @@ fileprivate func namespacedKeychainAccount(walletIdHex: String, derivationPath: "identity_privkey.\(walletIdHex).\(derivationPath)" } -/// Construct the OLD (no-walletId) keychain account name. Used only -/// for the post-rederive cleanup sweep. -fileprivate func legacyKeychainAccount(derivationPath: String) -> String { - "identity_privkey.\(derivationPath)" -} - /// Best-effort delete of the legacy (no-walletId) keychain entry for /// `derivationPath`, gated on its metadata's `walletId` matching the -/// wallet we just migrated from. Skips silently if the metadata says -/// the row belongs to a different wallet — never clobber data we -/// don't own. No-op when the row doesn't exist. +/// wallet we just migrated from. Delegates to +/// `KeychainManager.deleteLegacyKeychainEntryIfOwnedByWallet` so the +/// "don't clobber data we don't own" safety check is centralized — +/// shared with `KeychainManager.deleteIdentityPrivateKey`'s sweep. /// /// Called after a successful re-derive in /// `WalletKeyHealthChecker.rederive` so the keychain ends up with a /// single new-format entry per `(wallet, path)`. @MainActor fileprivate func cleanupLegacyKeychainEntry(walletIdHex: String, derivationPath: String) { - let account = legacyKeychainAccount(derivationPath: derivationPath) - let serviceName = KeychainManager.shared.serviceName - let lookupQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: account, - kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let lookupStatus = SecItemCopyMatching(lookupQuery as CFDictionary, &result) - guard lookupStatus == errSecSuccess, let attrs = result as? [String: Any] else { - return - } - guard let metadataData = attrs[kSecAttrGeneric as String] as? Data, - let metadata = try? JSONDecoder().decode( - IdentityPrivateKeyMetadata.self, - from: metadataData - ) - else { - return - } - guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { - // Different wallet's data sitting at the legacy account — - // leave it. (Pathological since we'd have to have lost the - // namespaced fix at some point, but defending it is cheap.) - return - } - - let deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: account, - ] - _ = SecItemDelete(deleteQuery as CFDictionary) + _ = KeychainManager.shared.deleteLegacyKeychainEntryIfOwnedByWallet( + walletIdHex: walletIdHex, + derivationPath: derivationPath + ) } /// Pure helpers — no UI state. Constructed lazily inside the sheet's From 03f088f27ad985f9d5d16b98ccdf5e74f270bbf5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 19:09:51 +0200 Subject: [PATCH 6/8] fix(swift-sdk): log silently-skipped rows + cargo fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteAllIdentityPrivateKeys(forIdentityIdBase58:) and the sibling forWalletId sweep used `try?` to skip rows whose metadata blob failed to decode, leaving orphan private bytes behind without telling anyone. Replaced both with explicit do/catch that surface the skip via os_log under subsystem=dashpay.SwiftDashSDK category=Keychain — still conservatively NOT deleting rows we can't prove we own, but the developer/user can now see them in Console.app instead of inferring silence. Also applied cargo fmt --all to the ContractBounds FFI extension to clear the macOS Tests workflow's `cargo fmt --check --all` gate that failed on commit 7ae00b8a85. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/identity_persistence.rs | 9 +-- .../Security/KeychainManager.swift | 69 +++++++++++++++---- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index c9c0d5f7ae5..673c1ad58d0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -514,7 +514,10 @@ impl IdentityKeyEntryFFI { let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = match entry.public_key.contract_bounds() { Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), - Some(ContractBounds::SingleContractDocumentType { id, document_type_name }) => { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { let doc_type_ptr = match CString::new(document_type_name.as_str()) { Ok(c) => c.into_raw() as *const c_char, Err(_) => ptr::null(), @@ -679,9 +682,7 @@ pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { // when the original entry carried `SingleContractDocumentType` // bounds (and the doc-type name didn't contain interior NULs). if !entry.contract_bounds_document_type.is_null() { - let _ = unsafe { - CString::from_raw(entry.contract_bounds_document_type as *mut c_char) - }; + let _ = unsafe { CString::from_raw(entry.contract_bounds_document_type as *mut c_char) }; entry.contract_bounds_document_type = ptr::null(); } // No private-key heap allocations to reclaim — the new FFI shape diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index b8b53563b48..b37a9c8c962 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -1,5 +1,6 @@ import Foundation import Security +import os.log // MARK: - Supporting Types @@ -946,12 +947,30 @@ extension KeychainManager { else { continue } - guard let metadataData = item[kSecAttrGeneric as String] as? Data, - let metadata = try? decoder.decode( - IdentityPrivateKeyMetadata.self, - from: metadataData - ) - else { + // Surface undecodable-metadata rows via os_log rather + // than silently skipping them. The caller has asked + // for a wholesale identity-scoped wipe, so the safest + // action is still to NOT delete a row we can't prove + // we own — but the developer needs to know that an + // `identity_privkey.*` row exists whose metadata we + // can't read (likely a partial / aborted write, a + // legacy pre-metadata row, or a future schema rename), + // because the user is being told the orphan was + // cleaned up. Visible in Console.app under the + // `dashpay.SwiftDashSDK` subsystem. + guard let metadataData = item[kSecAttrGeneric as String] as? Data else { + Self.log.error( + "Skipping identity_privkey row at account \(account, privacy: .public) — missing kSecAttrGeneric metadata blob; private bytes remain" + ) + continue + } + let metadata: IdentityPrivateKeyMetadata + do { + metadata = try decoder.decode(IdentityPrivateKeyMetadata.self, from: metadataData) + } catch { + Self.log.error( + "Skipping identity_privkey row at account \(account, privacy: .public) — metadata JSON decode failed (\(String(describing: error), privacy: .public)); private bytes remain" + ) continue } guard metadata.identityId == identityIdBase58 else { @@ -961,6 +980,21 @@ extension KeychainManager { } } + /// Subsystem-tagged logger for Keychain operations. Surfaces + /// silently-skipped rows from the wholesale sweeps so the + /// developer/user is aware that some `identity_privkey.*` + /// rows were not touched despite the cleanup running. Filter + /// in Console.app with `subsystem:dashpay.SwiftDashSDK + /// category:Keychain`. + /// + /// `nonisolated` so the keychain helpers (which are + /// `nonisolated` themselves — they run on the FFI signer + /// trampoline's worker thread) can reach the logger without + /// hopping to the main actor. `Logger` is already `Sendable`, + /// so plain `nonisolated` (not `nonisolated(unsafe)`) is the + /// right escape hatch. + fileprivate nonisolated static let log = Logger(subsystem: "dashpay.SwiftDashSDK", category: "Keychain") + /// Delete every `identity_privkey.` keychain row whose /// `IdentityPrivateKeyMetadata.walletId` matches `walletId`. public nonisolated func deleteAllIdentityPrivateKeys(forWalletId walletId: Data) throws { @@ -991,12 +1025,23 @@ extension KeychainManager { else { continue } - guard let metadataData = item[kSecAttrGeneric as String] as? Data, - let metadata = try? decoder.decode( - IdentityPrivateKeyMetadata.self, - from: metadataData - ) - else { + // Same logging-on-skip rationale as + // `deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` + // above — see that helper's doc comment for why we + // refuse to delete rows we can't prove we own. + guard let metadataData = item[kSecAttrGeneric as String] as? Data else { + Self.log.error( + "Skipping identity_privkey row at account \(account, privacy: .public) — missing kSecAttrGeneric metadata blob; private bytes remain" + ) + continue + } + let metadata: IdentityPrivateKeyMetadata + do { + metadata = try decoder.decode(IdentityPrivateKeyMetadata.self, from: metadataData) + } catch { + Self.log.error( + "Skipping identity_privkey row at account \(account, privacy: .public) — metadata JSON decode failed (\(String(describing: error), privacy: .public)); private bytes remain" + ) continue } guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { From 03c5405372744e69c587d0c5f6988b6ca508dc2c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 19:38:36 +0200 Subject: [PATCH 7/8] fix(platform-wallet-ffi,swift-sdk): close ContractBounds restore + 4 sub-fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3765 follow-up review found five issues from the prior round. Addressed in this commit: 1. Rust FFI `IdentityKeyEntryFFI::from_entry` was emitting kind=2 with a null doc-type pointer on `CString::new` failure (interior-NUL — DPP rejects them upstream so unreachable today, but the discriminant + payload were inconsistent). Now demotes to kind=1 (`SingleContract { id }`) so the contract id is preserved and the Swift consumer sees a coherent variant. 2. PersistentPublicKey.contractBounds setter cleared only `contractBoundsData`, leaving the new `contractBoundsDocumentTypeName` column stale across variant changes. Setter now also nils the doc-type column; the persister continues to write it explicitly after the setter, atomically. 3. `toIdentityPublicKey()` could construct a `ContractBounds` from a corrupt short / over-long id; downstream FFI marshalling hard-asserts 32 bytes and would crash on the NEXT call. Added an `id.count == 32` gate; a wrong-length id drops the bounds projection but keeps the key. 4. Persist↔restore round-trip closure. `IdentityKeyRestoreFFI` was missing contract-bounds fields, so cold-restart loaded scoped DashPay keys as unbounded. Extended the Rust struct with `contract_bounds_{kind,id, document_type}` (mirrors the persist-side encoding); `build_identity_public_keys` reconstructs the variant; Swift's `buildIdentityRestoreBuffer` reads from the persisted columns and emits the same trio (CString allocation reuses `cStringBuffers` for release). 5. `WalletKeyHealthChecker.deleteOrphan` was throwing `keychainError` even when SwiftData succeeded, so the caller (`deleteIdentity` in the sheet) treated it as a full failure and kept the deleted row in `reports` — stale UI. Returns `OrphanDeleteOutcome { swiftDataDeleted, keychainError }` instead; caller drops the row on SwiftData success and surfaces the keychain side as a non-blocking warning. Plus a follow-on consistency fix found in the same review: `KeychainManager.deleteLegacyKeychainEntryIfOwnedByWallet` was silently no-op'ing on malformed metadata while the wholesale sweeps log via `os_log`. Added matching log lines so all three "skip, can't verify ownership" branches surface in Console.app uniformly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/identity_persistence.rs | 23 ++++---- .../rs-platform-wallet-ffi/src/persistence.rs | 38 +++++++++++- .../src/wallet_restore_types.rs | 29 +++++++-- .../Models/PersistentPublicKey.swift | 22 ++++++- .../PlatformWalletPersistenceHandler.swift | 36 +++++++++++ .../Security/KeychainManager.swift | 30 +++++++--- .../Core/Views/WalletKeyHealthSheet.swift | 59 +++++++++++++++---- 7 files changed, 197 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 673c1ad58d0..d8f9871f6b8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -507,23 +507,24 @@ impl IdentityKeyEntryFFI { // Project the DPP `ContractBounds` enum into the kind / // id / doc-type-cstring trio so the Swift side can switch // on a single discriminant. Strings containing interior - // NULs (impossible in practice — DPP rejects them) fall - // back to a null pointer, leaving the kind tag set to 2; - // Swift treats null doc-type-with-kind-2 as "no bounds" - // rather than constructing a half-formed variant. + // NULs (impossible in practice — DPP rejects them) keep + // the discriminant + payload self-consistent by falling + // back to `SingleContract { id }` (kind=1 + null doc-type + // pointer); emitting kind=2 with a null doc-type pointer + // would silently strip the bound on the Swift side, so + // demoting to `SingleContract` is the closest faithful + // representation — the document-type qualifier is the + // only thing lost, the contract id is preserved. let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = match entry.public_key.contract_bounds() { Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), Some(ContractBounds::SingleContractDocumentType { id, document_type_name, - }) => { - let doc_type_ptr = match CString::new(document_type_name.as_str()) { - Ok(c) => c.into_raw() as *const c_char, - Err(_) => ptr::null(), - }; - (2u8, id.to_buffer(), doc_type_ptr) - } + }) => match CString::new(document_type_name.as_str()) { + Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), + Err(_) => (1u8, id.to_buffer(), ptr::null()), + }, None => (0u8, [0u8; 32], ptr::null()), }; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 072a0ea50ab..5a860e3bed9 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3101,6 +3101,7 @@ fn build_wallet_identity_bucket( unsafe fn build_identity_public_keys( spec: &IdentityRestoreEntryFFI, ) -> BTreeMap { + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; let mut map: BTreeMap = BTreeMap::new(); if spec.keys.is_null() || spec.keys_count == 0 { return map; @@ -3120,11 +3121,46 @@ unsafe fn build_identity_public_keys( continue; } let bytes: Vec = slice::from_raw_parts(row.data, row.data_len).to_vec(); + + // Reconstruct the ContractBounds variant from the kind tag + // + id + optional doc-type C-string trio. Mirrors the + // encoding in `IdentityKeyEntryFFI::from_entry`. A kind=2 + // row with a null doc-type pointer is an FFI-side + // inconsistency (the writer is supposed to demote to + // kind=1 in that case — see identity_persistence.rs); we + // demote it here too rather than fabricating an empty doc- + // type name. Invalid kind tags load as unbounded so a + // forward-compatible writer doesn't lock us out. + let contract_bounds: Option = match row.contract_bounds_kind { + 0 => None, + 1 => Some(ContractBounds::SingleContract { + id: row.contract_bounds_id.into(), + }), + 2 => { + if row.contract_bounds_document_type.is_null() { + Some(ContractBounds::SingleContract { + id: row.contract_bounds_id.into(), + }) + } else { + match CStr::from_ptr(row.contract_bounds_document_type).to_str() { + Ok(name) => Some(ContractBounds::SingleContractDocumentType { + id: row.contract_bounds_id.into(), + document_type_name: name.to_string(), + }), + Err(_) => Some(ContractBounds::SingleContract { + id: row.contract_bounds_id.into(), + }), + } + } + } + _ => None, + }; + let pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { id: row.key_id, purpose, security_level, - contract_bounds: None, + contract_bounds, key_type, read_only: row.read_only, data: BinaryData::new(bytes), diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index b3c42be2e6f..c9ef0019914 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -182,12 +182,16 @@ pub struct AccountSpecFFI { /// BLS → 48; etc.). The pointer is Swift-owned and valid only for the /// duration of the load callback. /// -/// Disabled-at, contract-bounds and other non-essential fields are -/// intentionally omitted — they're either always `None` for newly -/// derived identity-auth keys or get re-populated by the next -/// identity sync round if they exist on chain. The scope of this -/// restore is narrowly "make `Identity.public_keys` non-empty so -/// auth-key gates pass". +/// `contract_bounds_*` mirror the [`IdentityKeyEntryFFI`] +/// projection of DPP's `ContractBounds` enum (kind tag: 0=none, +/// 1=SingleContract, 2=SingleContractDocumentType). Including them +/// here closes the persist↔restore round-trip — without it, scoped +/// DashPay keys (registered with `SingleContractDocumentType`) come +/// back as unbounded on cold restart. +/// +/// Disabled-at and other non-essential fields remain omitted — +/// they're either always `None` for newly derived identity-auth +/// keys or get re-populated by the next identity sync round. #[repr(C)] pub struct IdentityKeyRestoreFFI { pub key_id: u32, @@ -199,6 +203,19 @@ pub struct IdentityKeyRestoreFFI { /// Valid for callback duration only; Swift owns the allocation. pub data: *const u8, pub data_len: usize, + /// ContractBounds discriminant: 0=none, 1=SingleContract, + /// 2=SingleContractDocumentType. Mirrors the encoding in + /// [`crate::identity_persistence::IdentityKeyEntryFFI`]. + pub contract_bounds_kind: u8, + /// 32-byte contract identifier. Zeroed when + /// `contract_bounds_kind == 0`; otherwise the contract id the + /// key is bound to. + pub contract_bounds_id: [u8; 32], + /// NUL-terminated UTF-8 doc-type name. Non-null iff + /// `contract_bounds_kind == 2`. Swift-owned (released by the + /// same load-callback allocation arena that frees the public- + /// key data buffer). + pub contract_bounds_document_type: *const c_char, } /// Per-identity entry attached to a [`WalletRestoreEntryFFI`]. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index 9372785adb3..f30cd83dd34 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -87,6 +87,18 @@ public final class PersistentPublicKey { return strings.compactMap { Data(base64Encoded: $0) } } set { + // Always clear the doc-type column when the contract- + // bounds ids change through this setter. The + // `documentTypeName` is paired with a SPECIFIC id, so + // mutating ids without explicitly carrying the doc- + // type would leave the columns inconsistent and make + // `toIdentityPublicKey()` reconstruct a stale variant. + // Callers that want the full `.singleContractDocumentType` + // round-trip should write `contractBoundsDocumentTypeName` + // explicitly after this setter, or go through + // `PersistentPublicKey.from(IdentityPublicKey, identityId:)` + // which sets both columns atomically. + contractBoundsDocumentTypeName = nil if let newValue = newValue { contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) } else { @@ -140,8 +152,16 @@ extension PersistentPublicKey { return nil } + // Validate the persisted id is the canonical 32-byte + // contract identifier before constructing a + // `ContractBounds` variant. Downstream FFI marshalling + // (`ManagedPlatformWallet.pinContractBounds`) hard-asserts + // a 32-byte payload, so a corrupt / short / over-long + // row would crash on the NEXT call instead of being + // rejected here. Drop the bounds projection on length + // mismatch — the rest of the key is still recoverable. let bounds: ContractBounds? - if let id = contractBounds?.first { + if let id = contractBounds?.first, id.count == 32 { if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) } else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 718124f8373..a64c724dd20 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3788,6 +3788,42 @@ public class PlatformWalletPersistenceHandler { row.data = nil row.data_len = 0 } + + // Mirror the contract-bounds projection into + // the restore row so scoped keys (DashPay's + // SingleContractDocumentType, in particular) + // come back with their full variant on cold + // restart instead of silently degrading to + // unbounded. Encoding matches + // `IdentityKeyEntryFFI` on the persist side: + // * kind=0 → no bounds; id zeroed, doc-type null + // * kind=1 → SingleContract; id meaningful + // * kind=2 → SingleContractDocumentType; id + + // doc-type both meaningful + // Length-validated by `pk.publicKeyData.count + // == 32` (matches the gating in + // `toIdentityPublicKey()`); a row with a + // wrong-length id falls back to "no bounds" + // rather than crashing FFI marshalling on the + // Rust side. + if let id = pk.contractBounds?.first, id.count == 32 { + withUnsafeMutableBytes(of: &row.contract_bounds_id) { dst in + id.copyBytes(to: dst.bindMemory(to: UInt8.self).baseAddress!, count: 32) + } + if let docType = pk.contractBoundsDocumentTypeName, !docType.isEmpty { + row.contract_bounds_kind = 2 + row.contract_bounds_document_type = UnsafePointer( + duplicateCString(docType, allocation: allocation) + ) + } else { + row.contract_bounds_kind = 1 + row.contract_bounds_document_type = nil + } + } else { + row.contract_bounds_kind = 0 + row.contract_bounds_document_type = nil + } + keyBuf[k] = row } entry.keys = UnsafePointer(keyBuf) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift index b37a9c8c962..d8a5751fb06 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift @@ -879,16 +879,30 @@ extension KeychainManager { guard lookupStatus == errSecSuccess, let attrs = result as? [String: Any] else { return false } - guard let metadataData = attrs[kSecAttrGeneric as String] as? Data, - let metadata = try? JSONDecoder().decode( + // Row exists but metadata is malformed / missing. Be + // conservative: refuse to delete data we can't prove we + // own. Surface via `os_log` so the per-key legacy path + // is symmetric with the wholesale sweeps + // (`deleteAllIdentityPrivateKeys(forIdentityIdBase58:)` + // and `forWalletId:`) — all three "skip, can't verify + // ownership" branches land in Console.app uniformly. + // Treat as success: we successfully decided not to delete. + guard let metadataData = attrs[kSecAttrGeneric as String] as? Data else { + Self.log.error( + "Skipping legacy identity_privkey row at account \(legacyAccount, privacy: .public) — missing kSecAttrGeneric metadata blob; private bytes remain" + ) + return true + } + let metadata: IdentityPrivateKeyMetadata + do { + metadata = try JSONDecoder().decode( IdentityPrivateKeyMetadata.self, from: metadataData - ) - else { - // Row exists but metadata is malformed / missing. Be - // conservative: refuse to delete data we can't prove - // we own. Treat as success — we successfully decided - // not to delete. + ) + } catch { + Self.log.error( + "Skipping legacy identity_privkey row at account \(legacyAccount, privacy: .public) — metadata JSON decode failed (\(String(describing: error), privacy: .public)); private bytes remain" + ) return true } guard metadata.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift index 286a6fc3233..15004a1158b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -351,6 +351,18 @@ enum WalletKeyHealthChecker { return RederiveOutcome(success: fixed, failures: failures) } + /// Outcome of a single `deleteOrphan` call. Decouples the + /// SwiftData side of the delete (the bit that affects the + /// reactive UI — `@Query` results, the report list) from the + /// Keychain side (which is observability / cleanup, not UI- + /// reactive). The caller can drop the row from the report + /// list as long as `swiftDataDeleted == true`, even when + /// `keychainError` is non-nil. + struct OrphanDeleteOutcome { + let swiftDataDeleted: Bool + let keychainError: Error? + } + /// Cascade-delete an orphan identity from SwiftData AND wipe its /// associated Keychain entries. /// @@ -367,14 +379,17 @@ enum WalletKeyHealthChecker { /// `PlatformWalletPersistenceHandler.deleteWalletData`. /// 3. Delete the identity itself + save. /// - /// If the keychain wipe throws we still attempt the SwiftData - /// delete so the user can finish the operation; the keychain - /// error is surfaced to the caller after the deletes complete. + /// Returns a per-side outcome so the caller can distinguish + /// "fully clean" from "SwiftData clean, Keychain warning" — + /// the latter shouldn't prevent the sheet from dropping the + /// now-deleted row from its in-memory `reports` list. + /// Genuine SwiftData failures still throw (they're the ones + /// that keep the row visible and actionable). @MainActor static func deleteOrphan( identity: PersistentIdentity, modelContext: ModelContext - ) throws { + ) throws -> OrphanDeleteOutcome { // Snapshot the base58 id BEFORE the delete — once the row // is removed its computed accessor is invalid. let identityIdBase58 = identity.identityIdBase58 @@ -411,9 +426,10 @@ enum WalletKeyHealthChecker { modelContext.delete(identity) try modelContext.save() - if let keychainError { - throw keychainError - } + return OrphanDeleteOutcome( + swiftDataDeleted: true, + keychainError: keychainError + ) } } @@ -655,16 +671,33 @@ struct WalletKeyHealthSheet: View { private func deleteIdentity(_ report: WalletIdentityKeyHealthReport) { do { - try WalletKeyHealthChecker.deleteOrphan( + let outcome = try WalletKeyHealthChecker.deleteOrphan( identity: report.identityRow, modelContext: modelContext ) - actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…" - errorMessage = nil - // Drop the now-deleted row from the report list so the - // sheet doesn't try to render it again. - reports.removeAll { $0.id == report.id } + // SwiftData side succeeded → drop the now-deleted row + // from the report list regardless of the keychain + // side. Keeping it in `reports` after the SwiftData + // delete leaves stale UI (the row's relationship + // accessors are invalidated and any action against + // them throws). A keychain-only failure becomes a + // non-blocking warning instead of suppressing the + // success state. + if outcome.swiftDataDeleted { + reports.removeAll { $0.id == report.id } + } + if let keychainError = outcome.keychainError { + actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…" + errorMessage = "Keychain cleanup warning: \(keychainError.localizedDescription) — re-running Verify Identity Keys can retry." + } else { + actionMessage = "Deleted orphan identity \(report.identityIdBase58.prefix(12))…" + errorMessage = nil + } } catch { + // SwiftData delete failed (the in-memory `reports` + // list is still authoritative); surface as a hard + // error and leave the row visible so the user can + // retry or escalate. errorMessage = "Delete failed: \(error.localizedDescription)" } } From 8f65db249125226eeb8ea2b3991b9fb2f061134a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 28 May 2026 19:40:18 +0200 Subject: [PATCH 8/8] docs(platform-wallet-ffi): document single-owner expectation on contract_bounds_document_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per coderabbit feedback on 3319620994 — `free_identity_key_entry_ffi` is idempotent, but a copied-then-double-freed struct value would still trigger UB. Spell out the supported consumption pattern (Swift binding copies the doc-type into an owned String inside the callback) so the constraint is explicit for future maintainers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet-ffi/src/identity_persistence.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index d8f9871f6b8..03065120b7b 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -206,6 +206,16 @@ pub struct IdentityKeyEntryFFI { // the Swift side switch on a single discriminant without // probing pointer values, matching how the rest of this struct // encodes optional payloads. + // + // Ownership: `contract_bounds_document_type` is owned by this + // struct EXCLUSIVELY when it is populated by + // [`IdentityKeyEntryFFI::from_entry`]. Consumers MUST NOT + // copy the struct value and then free both copies — the second + // free is a use-after-free / double-free. The Swift binding + // copies the doc-type into an owned Swift `String` inside the + // callback (per `persistIdentityKeysCallback`) and never + // retains the raw pointer past the callback window, which is + // the only supported consumption pattern. pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *const c_char,