diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index f01200fe1..c6eb02e2a 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -620,6 +620,14 @@ 5AEDE0012F9B000000000004 /* EvonodeWithdrawalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEDE0012F9B000000000001 /* EvonodeWithdrawalViewModel.swift */; }; 5AEDE0012F9B000000000005 /* EvonodeWithdrawalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEDE0012F9B000000000002 /* EvonodeWithdrawalScreen.swift */; }; 5AEDE0012F9B000000000006 /* EvonodeWithdrawalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEDE0012F9B000000000002 /* EvonodeWithdrawalScreen.swift */; }; + 5AFDE1012FA1000000000011 /* TrackedMasternodeKeyVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000001 /* TrackedMasternodeKeyVault.swift */; }; + 5AFDE1012FA1000000000012 /* TrackedMasternodeKeyVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000001 /* TrackedMasternodeKeyVault.swift */; }; + 5AFDE1012FA1000000000013 /* AddMasternodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000002 /* AddMasternodeViewModel.swift */; }; + 5AFDE1012FA1000000000014 /* AddMasternodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000002 /* AddMasternodeViewModel.swift */; }; + 5AFDE1012FA1000000000015 /* AddMasternodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */; }; + 5AFDE1012FA1000000000016 /* AddMasternodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */; }; + 5AFDE1012FA1000000000017 /* TrackedMasternodeDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */; }; + 5AFDE1012FA1000000000018 /* TrackedMasternodeDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */; }; 5AE5700E2F9C000000000003 /* EvonodeStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */; }; 5AE5700E2F9C000000000004 /* EvonodeStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */; }; 5AE5700E2F9C000000000005 /* EvonodeStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */; }; @@ -2819,6 +2827,10 @@ 51AA00202F970020005A0020 /* MasternodesScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodesScreen.swift; sourceTree = ""; }; 5AEDE0012F9B000000000001 /* EvonodeWithdrawalViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeWithdrawalViewModel.swift; sourceTree = ""; }; 5AEDE0012F9B000000000002 /* EvonodeWithdrawalScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeWithdrawalScreen.swift; sourceTree = ""; }; + 5AFDE1012FA1000000000001 /* TrackedMasternodeKeyVault.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackedMasternodeKeyVault.swift; sourceTree = ""; }; + 5AFDE1012FA1000000000002 /* AddMasternodeViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddMasternodeViewModel.swift; sourceTree = ""; }; + 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddMasternodeScreen.swift; sourceTree = ""; }; + 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackedMasternodeDetailScreen.swift; sourceTree = ""; }; 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeStatusViewModel.swift; sourceTree = ""; }; 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeStatusScreen.swift; sourceTree = ""; }; 51BA2E9F2F0B2E0100A1B201 /* SwapPendingGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPendingGate.swift; sourceTree = ""; }; @@ -3776,6 +3788,7 @@ 5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */, 5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */, 5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */, + 5AFDE1012FA1000000000001 /* TrackedMasternodeKeyVault.swift */, ); path = Masternodes; sourceTree = ""; @@ -3789,6 +3802,16 @@ path = "Masternode Withdrawal"; sourceTree = ""; }; + 5AFDE1012FA1000000000005 /* Tracked Masternodes */ = { + isa = PBXGroup; + children = ( + 5AFDE1012FA1000000000002 /* AddMasternodeViewModel.swift */, + 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */, + 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */, + ); + path = "Tracked Masternodes"; + sourceTree = ""; + }; 5AE5700E2F9C000000000007 /* Evonode Status */ = { isa = PBXGroup; children = ( @@ -4841,6 +4864,7 @@ FB3FF0B0222641210059A9A5 /* Masternode Keys */, 51AA00202F970020005A0020 /* MasternodesScreen.swift */, 5AEDE0012F9B000000000007 /* Masternode Withdrawal */, + 5AFDE1012FA1000000000005 /* Tracked Masternodes */, 5AE5700E2F9C000000000007 /* Evonode Status */, 7566F4822BB69498005238D2 /* ToolsMenuScreen.swift */, RC00C0032FA0000000000001 /* CSVExportSheet.swift */, @@ -10224,6 +10248,10 @@ 51AA00222F970022005A0022 /* MasternodesScreen.swift in Sources */, 5AEDE0012F9B000000000004 /* EvonodeWithdrawalViewModel.swift in Sources */, 5AEDE0012F9B000000000006 /* EvonodeWithdrawalScreen.swift in Sources */, + 5AFDE1012FA1000000000012 /* TrackedMasternodeKeyVault.swift in Sources */, + 5AFDE1012FA1000000000014 /* AddMasternodeViewModel.swift in Sources */, + 5AFDE1012FA1000000000016 /* AddMasternodeScreen.swift in Sources */, + 5AFDE1012FA1000000000018 /* TrackedMasternodeDetailScreen.swift in Sources */, 5AE5700E2F9C000000000004 /* EvonodeStatusViewModel.swift in Sources */, 5AE5700E2F9C000000000006 /* EvonodeStatusScreen.swift in Sources */, 51AA00022F970002005A0002 /* SyncInfoMenuScreen.swift in Sources */, @@ -11240,6 +11268,10 @@ 51AA00212F970021005A0021 /* MasternodesScreen.swift in Sources */, 5AEDE0012F9B000000000003 /* EvonodeWithdrawalViewModel.swift in Sources */, 5AEDE0012F9B000000000005 /* EvonodeWithdrawalScreen.swift in Sources */, + 5AFDE1012FA1000000000011 /* TrackedMasternodeKeyVault.swift in Sources */, + 5AFDE1012FA1000000000013 /* AddMasternodeViewModel.swift in Sources */, + 5AFDE1012FA1000000000015 /* AddMasternodeScreen.swift in Sources */, + 5AFDE1012FA1000000000017 /* TrackedMasternodeDetailScreen.swift in Sources */, 5AE5700E2F9C000000000003 /* EvonodeStatusViewModel.swift in Sources */, 5AE5700E2F9C000000000005 /* EvonodeStatusScreen.swift in Sources */, 51AA00032F970003005A0003 /* SyncInfoMenuScreen.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift index 29f1c97ac..854f7c6b7 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift @@ -104,9 +104,14 @@ final class EvonodeEpochBlocksMonitor: ObservableObject { static func activeEvonodeProTxHashes() -> Set { guard let manager = SwiftDashSDKHost.shared.manager, let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return [] } - return Set(manager.masternodes(for: walletId) - .filter { $0.isEvonode && MasternodeStatus(rawValue: $0.status) != .retired } - .map(\.proTxHash)) + let stillListed: (PlatformMasternode) -> Bool = { + $0.isEvonode && MasternodeStatus(rawValue: $0.status) != .retired + } + // Wallet evonodes plus the user's tracked ones — the epoch-blocks + // range scan is privacy-preserving either way (it never names the + // nodes), so tracked evonodes ride the same tally. + return Set(manager.masternodes(for: walletId).filter(stillListed).map(\.proTxHash)) + .union(manager.trackedMasternodes().filter(stillListed).map(\.proTxHash)) } // MARK: Triggers diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/TrackedMasternodeKeyVault.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/TrackedMasternodeKeyVault.swift new file mode 100644 index 000000000..687fde881 --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/TrackedMasternodeKeyVault.swift @@ -0,0 +1,165 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Security +import SwiftData +import SwiftDashSDK + +// MARK: - TrackedMasternodeKeyVaulting + +/// Secure storage for the private keys a user attaches to a TRACKED +/// (wallet-independent) masternode. +/// +/// The SDK deliberately never stores these: Rust receives a key per signing +/// call and retains nothing (`trackedMasternodeWithdraw`, +/// `castContestedResourceVote`). The app owns them, in the keychain, keyed by +/// `(network, proTxHash, role)`. Values are the key TEXT exactly as the user +/// supplied it (WIF / hex / node-key base64) — kept verbatim so it can be +/// re-shown and re-parsed by the same SDK parser that validated it. +/// +/// Callers gate every read behind `AuthenticationGate` — this type performs +/// no prompting of its own. Main-actor-bound like the SDK `KeychainManager` +/// APIs it wraps (and every caller already is). +@MainActor +protocol TrackedMasternodeKeyVaulting { + /// The stored key text for a role, or `nil`. + func key(for proTxHash: Data, role: MasternodeKeyRole) -> String? + /// Store (or replace) a role's key text. Returns `false` on a keychain + /// write failure — callers surface that, never assume success. + @discardableResult + func store(_ keyText: String, for proTxHash: Data, role: MasternodeKeyRole) -> Bool + /// Remove one role's key. + @discardableResult + func removeKey(for proTxHash: Data, role: MasternodeKeyRole) -> Bool + /// The roles that currently have a key attached for this node. + func attachedRoles(for proTxHash: Data) -> Set + /// Remove every key of this node (untrack). + func removeAllKeys(for proTxHash: Data) +} + +// MARK: - TrackedMasternodeKeyVault + +/// Keychain-backed vault (SwiftDashSDK `KeychainManager`, the same unified +/// service the SDK's identity keys use). Entries are per network so a node +/// tracked on both networks keeps distinct keys. +final class TrackedMasternodeKeyVault: TrackedMasternodeKeyVaulting { + private let keychain: KeychainManager + /// Resolved per call — the vault outlives network switches. Defaults to + /// the persisted network selection (`WalletEnvironment`), which is + /// readable off the main actor, unlike the host's published state. + private let network: () -> Network? + + nonisolated init(keychain: KeychainManager = .shared, + network: @escaping () -> Network? = { + WalletEnvironment.isTestnet ? .testnet : .mainnet + }) { + self.keychain = keychain + self.network = network + } + + /// The roles the tracked-masternode UI manages. `platformNode` and + /// `operatorPayout` are deliberately absent — no app action uses them + /// (owner decision 2026-08-24). + static let managedRoles: [MasternodeKeyRole] = [.owner, .voting, .operator, .ownerPayout] + + private func identifier(_ proTxHash: Data, _ role: MasternodeKeyRole) -> String? { + guard let network = network() else { return nil } + let hex = proTxHash.map { String(format: "%02x", $0) }.joined() + return "masternode.\(network.rawValue).\(hex).\(role.rawValue)" + } + + func key(for proTxHash: Data, role: MasternodeKeyRole) -> String? { + guard let identifier = identifier(proTxHash, role), + let data = keychain.retrieveKeyData(identifier: identifier) else { return nil } + return String(data: data, encoding: .utf8) + } + + @discardableResult + func store(_ keyText: String, for proTxHash: Data, role: MasternodeKeyRole) -> Bool { + guard let identifier = identifier(proTxHash, role) else { return false } + return keychain.storeKeyData(Data(keyText.utf8), identifier: identifier) != nil + } + + @discardableResult + func removeKey(for proTxHash: Data, role: MasternodeKeyRole) -> Bool { + guard let identifier = identifier(proTxHash, role) else { return false } + return keychain.deleteKeyData(identifier: identifier) + } + + func attachedRoles(for proTxHash: Data) -> Set { + Set(Self.managedRoles.filter { role in + guard let identifier = identifier(proTxHash, role) else { return false } + return keychain.retrieveKeyData(identifier: identifier) != nil + }) + } + + func removeAllKeys(for proTxHash: Data) { + for role in Self.managedRoles { + removeKey(for: proTxHash, role: role) + } + } +} + + +// MARK: - Reset-all cleanup + +extension TrackedMasternodeKeyVault { + /// Reset-all teardown (owner decision 2026-08-24: tracked masternodes + /// survive deleting one wallet of several, die on reset-all): untrack + /// everything the running manager knows (clears the Rust registry and + /// the current network's persisted rows), sweep any remaining + /// `PersistentTrackedMasternode` rows (the other network), and delete + /// every vaulted key. Idempotent and best-effort — a failed step logs + /// and the rest still runs. + @MainActor + static func wipeAllTrackedState() { + if let manager = SwiftDashSDKHost.shared.manager { + for node in manager.trackedMasternodes() { + _ = try? manager.untrackMasternode(proTxHash: node.proTxHash) + } + } + if let container = SwiftDashSDKHost.shared.modelContainer { + let context = ModelContext(container) + try? context.delete(model: PersistentTrackedMasternode.self) + try? context.save() + } + removeAllVaultedKeys() + } + + /// Delete every `masternode.*` entry in the unified keychain service, + /// across networks and nodes. + @MainActor + static func removeAllVaultedKeys() { + let service = KeychainManager.shared.serviceName + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let items = result as? [[String: Any]] else { return } + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("masternode.") else { continue } + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(deleteQuery as CFDictionary) + } + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift index 24812be3a..c760f7e2c 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift @@ -320,6 +320,19 @@ final class SwiftDashSDKWalletWiper: NSObject { logger.info( "wiped SwiftDashSDK wallets across mainnet/testnet in \(String(describing: elapsed), privacy: .public); authorization=\(authorization.logLabel, privacy: .public)") + // Reset-all also drops the user's TRACKED (wallet-independent) + // masternodes and their vaulted keys — they survive single-wallet + // deletion, not a full reset (owner decision 2026-08-24). Runs + // SYNCHRONOUSLY before the runtime teardown below: the cleanup + // needs the host's manager and model container, which + // `handleWalletWiped()` tears down. This body runs on the wipe + // executor's background queue, so the main hop cannot deadlock. + DispatchQueue.main.sync { + MainActor.assumeIsolated { + TrackedMasternodeKeyVault.wipeAllTrackedState() + } + } + // Tear down the app-owned runtime now that all wallet material is // gone. This stops BLAST/SPV, drops the host-owned manager/wallet, and // clears published wallet state. We do NOT delete public chain data; diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift index 59404559a..100c6af4c 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift @@ -30,6 +30,15 @@ import SwiftDashSDK /// aggregation joined against the wallet's derived voting-key pool, so a node /// appears if and only if the wallet actually holds the key Platform will /// check the signature against. +/// Where a votable node's voting private key lives. +enum VotingKeySource: Hashable { + /// Derived from the wallet's `ProviderVotingKeys` account at this index. + case walletIndex(UInt32) + /// Attached by the user to a TRACKED masternode; the key text is in the + /// app's keychain vault. + case trackedVault +} + struct VoterNode: Identifiable, Hashable { /// The masternode's pro_tx_hash in raw wire byte order — the orientation /// `SDK.castContestedResourceVote` expects. Reverse it only for display @@ -40,9 +49,8 @@ struct VoterNode: Identifiable, Hashable { let typeIndex: UInt32 /// `ip:port`, when the registration carried one. let serviceAddress: String? - /// Index into the wallet's `ProviderVotingKeys` account that derives this - /// node's voting key. - let votingKeyIndex: UInt32 + /// Where this node's voting private key comes from. + let keySource: VotingKeySource var id: Data { proTxHash } @@ -115,7 +123,14 @@ final class MasternodeVoterRegistry { let eligible = manager.masternodes(for: walletId) .filter { !$0.revoked && MasternodeStatus(rawValue: $0.status) == .active } - guard !eligible.isEmpty else { return .empty } + // Tracked (wallet-independent) nodes vote too — resolve them + // BEFORE any early return, or a wallet with no masternodes of its + // own (the common case for tracking) could never vote. + let trackedNodes = trackedVotableNodes( + excluding: Set(eligible.map(\.proTxHash))) + guard !eligible.isEmpty else { + return Resolution(nodes: trackedNodes, mayBeIncomplete: false) + } let resolution = MasternodeKeyUsage.resolveAddressIndexes( family: .voting, @@ -145,7 +160,7 @@ final class MasternodeVoterRegistry { isEvonode: masternode.isEvonode, typeIndex: masternode.typeIndex, serviceAddress: masternode.serviceAddress, - votingKeyIndex: index) + keySource: .walletIndex(index)) } // Only a degraded pool read can hide a node we own; if every active @@ -153,7 +168,33 @@ final class MasternodeVoterRegistry { let mayBeIncomplete = !resolution.poolIsLive && nodes.count < eligible.count Self.logger.info( "🗳️ VOTING :: votable nodes=\(nodes.count, privacy: .public) of \(eligible.count, privacy: .public) active registrations livePool=\(resolution.poolIsLive, privacy: .public)") - return Resolution(nodes: nodes, mayBeIncomplete: mayBeIncomplete) + // Exclusion is by the wallet's ELIGIBLE set (not just the votable + // subset): a wallet-registered node stays out of the tracked list + // even when its derived key didn't resolve. + return Resolution(nodes: nodes + trackedNodes, mayBeIncomplete: mayBeIncomplete) + } + + /// Tracked (wallet-independent) masternodes whose voting key the user + /// attached on this device — votable with that key. A node already + /// votable through the wallet is skipped (the derived key wins). + private func trackedVotableNodes(excluding walletHashes: Set) -> [VoterNode] { + guard let manager = SwiftDashSDKHost.shared.manager else { return [] } + let vault = TrackedMasternodeKeyVault() + return manager.trackedMasternodes() + .filter { node in + !walletHashes.contains(node.proTxHash) + && !node.revoked + && MasternodeStatus(rawValue: node.status) == .active + && vault.attachedRoles(for: node.proTxHash).contains(.voting) + } + .map { node in + VoterNode( + proTxHash: node.proTxHash, + isEvonode: node.isEvonode, + typeIndex: node.typeIndex, + serviceAddress: node.serviceAddress, + keySource: .trackedVault) + } } /// The 32-byte voting private key for `node`. @@ -166,6 +207,13 @@ final class MasternodeVoterRegistry { /// (wallet locked or not loaded) or the WIF fails to parse. Never /// substitutes a placeholder. func votingPrivateKey(for node: VoterNode) -> Data? { + let index: UInt32 + switch node.keySource { + case .trackedVault: + return trackedVotingPrivateKey(for: node) + case .walletIndex(let walletIndex): + index = walletIndex + } guard let deriver = MasternodeProviderKeyDeriver(key: .voting) else { Self.logger.error("🗳️ VOTING :: no provider-voting deriver available") return nil @@ -179,12 +227,12 @@ final class MasternodeVoterRegistry { // to report that as "no voter identity exists", which is // indistinguishable from a node that was never registered. Self.logger.info( - "🗳️ VOTING :: signing with the key at pool index \(node.votingKeyIndex, privacy: .public) for \(deriver.address(at: node.votingKeyIndex) ?? "unknown address", privacy: .private)") + "🗳️ VOTING :: signing with the key at pool index \(index, privacy: .public) for \(deriver.address(at: index) ?? "unknown address", privacy: .private)") - guard let hex = deriver.privateKeyHex(at: node.votingKeyIndex), + guard let hex = deriver.privateKeyHex(at: index), let key = Data(hex: hex) else { Self.logger.error( - "🗳️ VOTING :: failed to derive voting key at index \(node.votingKeyIndex, privacy: .public)") + "🗳️ VOTING :: failed to derive voting key at index \(index, privacy: .public)") return nil } guard key.count == 32 else { @@ -194,6 +242,24 @@ final class MasternodeVoterRegistry { } return key } + + /// The 32-byte voting key of a tracked node, from the keychain vault + /// (WIF or hex, exactly as the user attached it). Same + /// authenticate-first contract as the derived path. + private func trackedVotingPrivateKey(for node: VoterNode) -> Data? { + let vault = TrackedMasternodeKeyVault() + guard let text = vault.key(for: node.proTxHash, role: .voting) else { + Self.logger.error("🗳️ VOTING :: tracked node has no voting key in the vault") + return nil + } + let key = WIFParser.parseWIF(text) ?? Data(hex: text) + guard let key, key.count == 32 else { + Self.logger.error("🗳️ VOTING :: the vaulted voting key is not a 32-byte WIF/hex key") + return nil + } + Self.logger.info("🗳️ VOTING :: signing with a tracked node's vaulted voting key") + return key + } } // MARK: - Aggregate weight diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift index d06402e55..2b20348a8 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift @@ -619,8 +619,9 @@ struct EvonodeWithdrawalConfirmSheet: View { // MARK: - QRScannerRepresentable -/// SwiftUI wrapper for the UIKit `GenericQRScannerController`. -private struct QRScannerRepresentable: UIViewControllerRepresentable { +/// SwiftUI wrapper for the UIKit `GenericQRScannerController`. Internal: the +/// add-masternode locator field reuses it. +struct QRScannerRepresentable: UIViewControllerRepresentable { let onScanned: (String) -> Void let onCancel: () -> Void diff --git a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift index ef5fbbf28..10396b6ab 100644 --- a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift @@ -32,6 +32,10 @@ import UIKit @MainActor final class MasternodesViewModel: ObservableObject { @Published private(set) var masternodes: [PlatformMasternode] = [] + /// Masternodes the user tracks independently of every wallet (SDK + /// registry; `source == .tracked`). A node that is also one of the + /// wallet's own is shown only in the wallet list. + @Published private(set) var trackedMasternodes: [PlatformMasternode] = [] @Published private(set) var loaded = false /// Current-epoch proposal tallies for the wallet's evonodes, mirrored /// from the shared `EvonodeEpochBlocksMonitor` (privacy-preserving range @@ -86,10 +90,14 @@ final class MasternodesViewModel: ObservableObject { guard let manager = SwiftDashSDKHost.shared.manager, let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { masternodes = [] + trackedMasternodes = [] return } masternodes = manager.masternodes(for: walletId) .sorted { $0.orderIndex < $1.orderIndex } + let walletHashes = Set(masternodes.map(\.proTxHash)) + trackedMasternodes = manager.trackedMasternodes() + .filter { !walletHashes.contains($0.proTxHash) } guard !masternodes.isEmpty else { return } ownerIndexByAddress = MasternodeKeyUsage.indexByAddress( @@ -242,7 +250,7 @@ struct MasternodesScreen: View { var body: some View { List { - if viewModel.loaded && viewModel.masternodes.isEmpty { + if viewModel.loaded && viewModel.masternodes.isEmpty && viewModel.trackedMasternodes.isEmpty { Section { VStack(spacing: 12) { Image(systemName: "server.rack") @@ -256,6 +264,11 @@ struct MasternodesScreen: View { .font(.caption) .foregroundColor(Color.dash.secondaryText) .multilineTextAlignment(.center) + + Text(NSLocalizedString("You can also track any masternode on the network — by IP, proTxHash, or one of its keys — with the + button.", comment: "Add masternode")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 20) @@ -269,6 +282,14 @@ struct MasternodesScreen: View { } } + if !viewModel.trackedMasternodes.isEmpty { + Section(NSLocalizedString("Tracked", comment: "Tracked masternodes")) { + ForEach(viewModel.trackedMasternodes, id: \.proTxHash) { masternode in + trackedRow(for: masternode) + } + } + } + if !viewModel.retiredMasternodes.isEmpty { Section { DisclosureGroup(isExpanded: $isRetiredExpanded) { @@ -295,6 +316,28 @@ struct MasternodesScreen: View { .onAppear { viewModel.load() } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + AddMasternodeScreen(onTracked: { viewModel.load() }) + } label: { + Image(systemName: "plus") + } + .accessibilityLabel(NSLocalizedString("Add masternode", comment: "Add masternode")) + } + } + } + + private func trackedRow(for masternode: PlatformMasternode) -> some View { + NavigationLink { + TrackedMasternodeDetailScreen( + record: masternode, + onChanged: { viewModel.load() }) + } label: { + MasternodeListRow( + masternode: masternode, + epochBlocks: masternode.isEvonode ? viewModel.epochBlocks(for: masternode) : nil) + } } private func row(for masternode: PlatformMasternode) -> some View { @@ -328,7 +371,7 @@ private struct MasternodeListRow: View { var body: some View { HStack { VStack(alignment: .leading, spacing: 2) { - Text(masternode.displayTitle) + Text(masternode.label ?? masternode.displayTitle) .font(.subheadline) .fontWeight(.medium) @@ -712,7 +755,8 @@ struct MasternodeDetailScreen: View { // MARK: - Rows -/// Label + trailing value row. Internal: `EvonodeStatusScreen` reuses it. +/// Label + trailing value row. Internal: `EvonodeStatusScreen` and the +/// tracked-masternode screens reuse it. struct MasternodeDetailRow: View { let label: String let value: String @@ -732,7 +776,8 @@ struct MasternodeDetailRow: View { } /// Caption + monospaced, tap-to-copy value block for hashes / addresses. -/// Internal: `EvonodeStatusScreen` reuses it. +/// Internal: `EvonodeStatusScreen` and the tracked-masternode screens +/// reuse it. struct MasternodeCopyRow: View { let label: String let value: String diff --git a/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeScreen.swift new file mode 100644 index 000000000..5f16c6b03 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeScreen.swift @@ -0,0 +1,382 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftDashSDK +import SwiftUI + +// MARK: - AddMasternodeScreen + +/// Tools → Masternodes → Add masternode. +/// +/// One field finds the node — IP, proTxHash, or any of its private keys — +/// then the user tracks it (with an optional label) and can attach keys, +/// with the key that found the node already filled into its field. +struct AddMasternodeScreen: View { + @StateObject private var viewModel = AddMasternodeViewModel() + @Environment(\.dismiss) private var dismiss + @State private var showScanner = false + /// Fires after the node is tracked and this screen closes, so the list + /// refreshes immediately. + let onTracked: () -> Void + + var body: some View { + List { + if viewModel.trackedRecord == nil { + findSection + resultsSection + } else { + keysSection + } + } + .navigationTitle(NSLocalizedString("Add masternode", comment: "Add masternode")) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showScanner) { + QRScannerRepresentable( + onScanned: { value in + viewModel.query = value + showScanner = false + Task { await viewModel.search() } + }, + onCancel: { showScanner = false }) + .ignoresSafeArea() + } + } + + // MARK: Find + + private var findSection: some View { + Section { + HStack(spacing: 8) { + TextField( + NSLocalizedString("IP address, proTxHash or private key", comment: "Add masternode"), + text: $viewModel.query, + axis: .vertical) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onSubmit { Task { await viewModel.search() } } + + Button { + if let pasted = UIPasteboard.general.string { + viewModel.query = pasted.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await viewModel.search() } + } + } label: { + Image(systemName: "doc.on.clipboard") + } + .buttonStyle(.borderless) + + Button { + showScanner = true + } label: { + Image(systemName: "qrcode.viewfinder") + } + .buttonStyle(.borderless) + } + + Toggle(isOn: $viewModel.searchPlatform) { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Search Platform too", comment: "Add masternode")) + .font(.subheadline) + Text(NSLocalizedString( + "Finds owner and payout keys, which aren't in the masternode list. Sends the key's public fingerprint to a Platform node.", + comment: "Add masternode")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + + Button { + Task { await viewModel.search() } + } label: { + if viewModel.searching { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Searching…", comment: "Add masternode")) + } + } else { + Label(NSLocalizedString("Find", comment: "Add masternode"), systemImage: "magnifyingglass") + } + } + .disabled(viewModel.searching + || viewModel.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } footer: { + if let error = viewModel.searchError { + Text(error).foregroundColor(.red) + } else { + Text(NSLocalizedString( + "Track any masternode or evonode on the network — it doesn't have to belong to this wallet.", + comment: "Add masternode")) + } + } + } + + @ViewBuilder + private var resultsSection: some View { + if let matches = viewModel.matches { + Section(NSLocalizedString("Matches", comment: "Add masternode")) { + if matches.isEmpty { + Text(NSLocalizedString("No masternode on the list matches that.", comment: "Add masternode")) + .font(.subheadline) + .foregroundColor(Color.dash.secondaryText) + } + ForEach(matches, id: \.proTxHash) { match in + matchRow(match) + } + if let note = viewModel.platformLookupNote { + Text(note) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + if let error = viewModel.trackError { + Section { Text(error).font(.caption).foregroundColor(.red) } + } + } + } + + private func matchRow(_ match: MasternodeLocateMatch) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label( + match.isEvonode + ? NSLocalizedString("Evonode", comment: "") + : NSLocalizedString("Masternode", comment: "Masternodes"), + systemImage: "server.rack") + .font(.subheadline) + .fontWeight(.medium) + Spacer() + Text(match.isValid + ? NSLocalizedString("Active", comment: "Masternode status") + : NSLocalizedString("PoSe banned", comment: "Masternode status")) + .font(.caption) + .foregroundColor(match.isValid ? .green : .orange) + } + if let service = match.serviceAddress { + Text(service) + .font(.system(.footnote, design: .monospaced)) + } + Text(match.proTxHashHex) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(Color.dash.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + if !match.matchedKeys.isEmpty { + Text(matchedKeyCaption(match.matchedKeys)) + .font(.caption) + .foregroundColor(Color.dash.blue) + } + + if match.inWalletId != nil { + Text(NSLocalizedString( + "Already one of this wallet's masternodes — it's in the list above.", + comment: "Add masternode")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } else if match.alreadyTracked { + Text(NSLocalizedString("Already tracked.", comment: "Add masternode")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } else { + Button { + Task { await viewModel.track(match) } + } label: { + if viewModel.tracking { + SwiftUI.ProgressView().scaleEffect(0.8) + } else { + Text(NSLocalizedString("Track this masternode", comment: "Add masternode")) + .fontWeight(.medium) + } + } + .disabled(viewModel.tracking) + .buttonStyle(.borderless) + } + } + .padding(.vertical, 2) + } + + private func matchedKeyCaption(_ roles: [MasternodeKeyRole]) -> String { + let names = roles.map { $0.displayName }.joined(separator: ", ") + return String( + format: NSLocalizedString("Matches this node's %@ key", comment: "Add masternode"), + names) + } + + // MARK: Keys + + private var keysSection: some View { + Group { + Section { + if let record = viewModel.trackedRecord { + HStack { + Label(record.typeName, systemImage: "checkmark.circle.fill") + .font(.subheadline) + .foregroundColor(.green) + Spacer() + Text(record.serviceAddress ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(Color.dash.secondaryText) + } + } + TextField(NSLocalizedString("Label (optional)", comment: "Add masternode"), text: $viewModel.label) + .onSubmit { viewModel.saveLabelIfChanged() } + } footer: { + Text(NSLocalizedString( + "Tracked. Add this node's keys below to enable actions like withdrawing and voting, or finish now and add them later.", + comment: "Add masternode")) + } + + TrackedKeyFieldsSection(viewModel: viewModel) + + Section { + Button { + viewModel.saveLabelIfChanged() + if viewModel.saveKeys() { + onTracked() + dismiss() + } + } label: { + Text(NSLocalizedString("Done", comment: "")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + .disabled(!viewModel.canSaveKeys) + } footer: { + if !viewModel.canSaveKeys { + Text(NSLocalizedString( + "A key doesn't match this masternode — fix or clear it to continue.", + comment: "Add masternode")) + .foregroundColor(.red) + } else if let error = viewModel.trackError { + Text(error).foregroundColor(.red) + } + } + } + } + +} + +// MARK: - TrackedKeyFieldsSection + +/// The four key fields (owner / voting / operator / payout) with live +/// verification badges. Shared by the add flow and the detail screen's key +/// management. +struct TrackedKeyFieldsSection: View { + @ObservedObject var viewModel: AddMasternodeViewModel + + var body: some View { + Section(NSLocalizedString("Keys", comment: "Masternodes")) { + if viewModel.refreshing { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Loading registration details…", comment: "Add masternode")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } else if let note = viewModel.refreshNote { + Text(note) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + ForEach(AddMasternodeViewModel.formRoles, id: \.rawValue) { role in + keyField(role) + } + } + } + + private func keyField(_ role: MasternodeKeyRole) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(role.displayName) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + Spacer() + stateBadge(viewModel.keyStates[role] ?? .empty) + } + // A plain field, deliberately not `SecureField`: iOS treats + // secure fields as password entries and pushes the strong- + // password / AutoFill sheet over them, which is nonsense for a + // masternode key (and blocks pasting). Keys are entered by + // paste and verified inline, so visible monospaced text is the + // useful presentation. + TextField(role.inputPlaceholder, text: binding(for: role), axis: .vertical) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .onSubmit { viewModel.revalidateKeys() } + } + .padding(.vertical, 2) + } + + private func binding(for role: MasternodeKeyRole) -> Binding { + Binding( + get: { viewModel.keyInputs[role] ?? "" }, + set: { newValue in + viewModel.keyInputs[role] = newValue + viewModel.revalidateKeys() + }) + } + + @ViewBuilder + private func stateBadge(_ state: AddMasternodeViewModel.KeyFieldState) -> some View { + switch state { + case .empty: + EmptyView() + case .matches: + Label(NSLocalizedString("Matches", comment: "Add masternode"), systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + case .doesNotMatch: + Label(NSLocalizedString("Doesn't match", comment: "Add masternode"), systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.red) + case .unverifiable: + Label(NSLocalizedString("Can't verify yet", comment: "Add masternode"), systemImage: "questionmark.circle") + .font(.caption) + .foregroundColor(.orange) + case .invalid(let message): + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.red) + } + } +} + +// MARK: - Role display names + +extension MasternodeKeyRole { + /// Short human name for a key role. + var displayName: String { + switch self { + case .owner: return NSLocalizedString("Owner", comment: "Masternodes") + case .voting: return NSLocalizedString("Voting", comment: "Masternodes") + case .operator: return NSLocalizedString("Operator", comment: "Masternodes") + case .platformNode: return NSLocalizedString("Platform node", comment: "Masternodes") + case .ownerPayout: return NSLocalizedString("Payout", comment: "Masternodes") + case .operatorPayout: return NSLocalizedString("Operator payout", comment: "Masternodes") + } + } + + /// Placeholder describing the accepted key format. + var inputPlaceholder: String { + switch self { + case .operator: + return NSLocalizedString("BLS private key (hex)", comment: "Add masternode") + case .platformNode: + return NSLocalizedString("Node key (base64 or hex)", comment: "Add masternode") + default: + return NSLocalizedString("Private key (WIF or hex)", comment: "Add masternode") + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeViewModel.swift new file mode 100644 index 000000000..f844e42d0 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/AddMasternodeViewModel.swift @@ -0,0 +1,265 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import SwiftDashSDK + +// MARK: - AddMasternodeViewModel + +/// Tools → Masternodes → Add: find any masternode / evonode on the network +/// by IP, proTxHash or one of its private keys (SDK locator), track it +/// independently of every wallet, and optionally attach keys. +/// +/// All lookup / matching / verification logic is in the SDK +/// (`locateMasternode`, `verifyMasternodeKey`, `trackMasternode`); this model +/// only sequences the flow and holds screen state. Keys the user attaches are +/// stored in the app's keychain vault — the SDK never retains them. +@MainActor +final class AddMasternodeViewModel: ObservableObject { + // MARK: Find step + + /// The locator text: IP (`1.2.3.4`, `1.2.3.4:9999`), proTxHash (explorer + /// hex), or a private key (owner / voting / payout WIF or hex, operator + /// BLS hex, Tenderdash node key). + @Published var query = "" + /// Also ask Platform for the owner / payout role of a pasted key. Owner + /// and payout keys are not on the masternode list, so without this they + /// can't be located — but the lookup reveals the key's public hash to a + /// DAPI node, so it stays opt-in (owner decision 2026-08-24). + @Published var searchPlatform = false + @Published private(set) var searching = false + /// `nil` = no search ran yet; empty = ran and found nothing. + @Published private(set) var matches: [MasternodeLocateMatch]? + @Published private(set) var searchError: String? + /// One-line outcome of the opt-in Platform step (unavailable / off), + /// shown under the results so "no match" is never mistaken for "checked + /// everywhere". + @Published private(set) var platformLookupNote: String? + + // MARK: Track + keys step + + /// The match being tracked (single-select; owner decision 2026-08-24). + @Published private(set) var selected: MasternodeLocateMatch? + @Published var label = "" + /// Key text per form role. Pre-filled with the query when the search + /// matched by that key, so it never has to be typed twice. + @Published var keyInputs: [MasternodeKeyRole: String] = [:] + @Published private(set) var keyStates: [MasternodeKeyRole: KeyFieldState] = [:] + @Published private(set) var tracking = false + @Published private(set) var trackError: String? + /// Set once the node is in the registry; the screen switches to the + /// keys step and the list behind it refreshes on dismiss. + @Published private(set) var trackedRecord: PlatformMasternode? + /// Background enrichment (Platform identities + ProRegTx) outcome — + /// owner / payout verification stays "can't verify yet" until it lands. + @Published private(set) var refreshing = false + @Published private(set) var refreshNote: String? + + enum KeyFieldState: Equatable { + case empty + case matches + case doesNotMatch + /// The reference for this role isn't known yet (owner / payout + /// before enrichment) — stored, but never claimed verified. + case unverifiable + case invalid(String) + } + + /// The four key roles the form offers (owner decision 2026-08-24; the + /// platform node key identifies a node but no app action uses it, and + /// operator payout is deferred with operator-identity withdrawals). + static let formRoles: [MasternodeKeyRole] = TrackedMasternodeKeyVault.managedRoles + + private let vault: TrackedMasternodeKeyVaulting + + init(vault: TrackedMasternodeKeyVaulting = TrackedMasternodeKeyVault()) { + self.vault = vault + } + + // MARK: - Find + + func search() async { + guard !searching else { return } + let text = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + guard let manager = SwiftDashSDKHost.shared.manager else { + searchError = NSLocalizedString("Wallet is not ready. Try again in a moment.", comment: "Evonode withdrawal") + return + } + searching = true + searchError = nil + matches = nil + platformLookupNote = nil + defer { searching = false } + do { + let result = try await manager.locateMasternode(text, searchPlatform: searchPlatform) + matches = result.matches + switch result.platformLookup { + case .notRequested: + platformLookupNote = NSLocalizedString( + "That could also be an owner or payout key — turn on “Search Platform too” to check.", + comment: "Add masternode") + case .unavailable: + platformLookupNote = NSLocalizedString( + "Platform could not be reached, so owner and payout keys were not checked.", + comment: "Add masternode") + case .notNeeded, .done: + platformLookupNote = nil + } + } catch { + matches = nil + searchError = (error as? PlatformWalletError)?.errorDescription ?? error.localizedDescription + } + } + + // MARK: - Track + + /// Track `match` and move to the keys step. Pre-fills every key field + /// the search already proved (`matchedKeys` ∋ role ⇒ the query text IS + /// that key), then starts the background enrichment that lets owner / + /// payout keys verify. + func track(_ match: MasternodeLocateMatch) async { + guard !tracking, trackedRecord == nil else { return } + guard let manager = SwiftDashSDKHost.shared.manager else { + trackError = NSLocalizedString("Wallet is not ready. Try again in a moment.", comment: "Evonode withdrawal") + return + } + tracking = true + trackError = nil + defer { tracking = false } + do { + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let record = try manager.trackMasternode( + proTxHash: match.proTxHash, + label: trimmedLabel.isEmpty ? nil : trimmedLabel) + selected = match + trackedRecord = record + + let keyText = query.trimmingCharacters(in: .whitespacesAndNewlines) + for role in match.matchedKeys where Self.formRoles.contains(role) { + keyInputs[role] = keyText + } + revalidateKeys() + await refreshDetails() + } catch { + trackError = (error as? PlatformWalletError)?.errorDescription ?? error.localizedDescription + } + } + + /// Fetch the node's Platform identities + registration transaction so + /// the owner / payout references become verifiable. Best-effort: a + /// failure leaves those fields "can't verify yet" and says so. + func refreshDetails() async { + guard let manager = SwiftDashSDKHost.shared.manager, + let record = trackedRecord else { return } + refreshing = true + refreshNote = nil + defer { refreshing = false } + do { + trackedRecord = try await manager.refreshTrackedMasternode(proTxHash: record.proTxHash) + } catch { + refreshNote = NSLocalizedString( + "Could not load the node's registration details — owner and payout keys will verify later.", + comment: "Add masternode") + } + revalidateKeys() + } + + /// Persist the label field if it changed since tracking (SDK call — + /// kept out of the View per the repo guardrails). + func saveLabelIfChanged() { + guard let record = trackedRecord, + let manager = SwiftDashSDKHost.shared.manager else { return } + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed != (record.label ?? "") else { return } + try? manager.setTrackedMasternodeLabel( + proTxHash: record.proTxHash, + label: trimmed.isEmpty ? nil : trimmed) + } + + /// Seed the model directly at the keys step for an already-tracked + /// node (the detail screen's key-management sheet). + func adoptTrackedRecord(_ record: PlatformMasternode) { + trackedRecord = record + revalidateKeys() + Task { await refreshDetails() } + } + + // MARK: - Keys + + /// Re-verify every non-empty key field against the SDK's references + /// (masternode list + tracked snapshot). Local; no network. + func revalidateKeys() { + guard let record = trackedRecord else { return } + guard let manager = SwiftDashSDKHost.shared.manager else { + // No SDK yet: nothing can be checked — mark every non-empty + // field "can't verify yet" so the badge (and the save caveat) + // never read as a silent pass. + keyStates = Dictionary(uniqueKeysWithValues: Self.formRoles.map { role in + let text = (keyInputs[role] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return (role, text.isEmpty ? KeyFieldState.empty : .unverifiable) + }) + return + } + var states: [MasternodeKeyRole: KeyFieldState] = [:] + for role in Self.formRoles { + let text = (keyInputs[role] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + states[role] = .empty + continue + } + do { + switch try manager.verifyMasternodeKey( + proTxHash: record.proTxHash, role: role, key: text) { + case .matches: states[role] = .matches + case .doesNotMatch: states[role] = .doesNotMatch + case .unverifiable: states[role] = .unverifiable + } + } catch { + states[role] = .invalid( + (error as? PlatformWalletError)?.errorDescription ?? error.localizedDescription) + } + } + keyStates = states + } + + /// Whether the keys step can complete: no field holds a key that is + /// invalid or provably wrong. Empty fields are fine (track-only), and + /// `unverifiable` saves with its caveat shown. + var canSaveKeys: Bool { + !keyStates.values.contains { state in + switch state { + case .doesNotMatch, .invalid: return true + case .empty, .matches, .unverifiable: return false + } + } + } + + /// Store every non-empty field in the keychain vault. Returns false + /// when a keychain write fails (the screen stays put and says so). + func saveKeys() -> Bool { + guard canSaveKeys, let record = trackedRecord else { return false } + for role in Self.formRoles { + let text = (keyInputs[role] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + guard vault.store(text, for: record.proTxHash, role: role) else { + trackError = NSLocalizedString( + "Could not save a key to the keychain. Nothing was lost — try again.", + comment: "Add masternode") + return false + } + } + return true + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift new file mode 100644 index 000000000..cf54a97f1 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift @@ -0,0 +1,629 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftDashSDK +import SwiftUI + +// MARK: - TrackedMasternodeDetailViewModel + +/// Detail state for one TRACKED (wallet-independent) masternode: the SDK +/// record (refreshable from the list / Platform / its ProRegTx), which key +/// roles have keys in the app's keychain vault, the claimable balance, and +/// the actions those keys enable. +@MainActor +final class TrackedMasternodeDetailViewModel: ObservableObject { + @Published private(set) var record: PlatformMasternode + @Published private(set) var attachedRoles: Set = [] + @Published private(set) var refreshing = false + @Published private(set) var refreshError: String? + + /// Claimable balance of the node's Platform identity (evonodes). + @Published private(set) var claimableCredits: UInt64? + @Published private(set) var balanceLoading = false + @Published private(set) var balanceError: String? + + /// Withdraw state (sheet). + @Published var withdrawAmountText = "" + @Published var withdrawDestination = "" + @Published private(set) var withdrawing = false + @Published private(set) var withdrawError: String? + + let vault: TrackedMasternodeKeyVaulting + + init(record: PlatformMasternode, vault: TrackedMasternodeKeyVaulting = TrackedMasternodeKeyVault()) { + self.record = record + self.vault = vault + reloadAttachedRoles() + } + + func reloadAttachedRoles() { + attachedRoles = vault.attachedRoles(for: record.proTxHash) + } + + /// What the attached keys enable — the SDK's shared gating policy. + var capabilities: MasternodeCapabilities { + MasternodeCapabilities(holding: attachedRoles) + } + + /// The role whose key signs a withdrawal, preferring the payout key + /// (it can choose a destination). `nil` when neither key is attached. + var withdrawalRole: MasternodeKeyRole? { + if attachedRoles.contains(.ownerPayout) { return .ownerPayout } + if attachedRoles.contains(.owner) { return .owner } + return nil + } + + func load() async { + await refresh() + if record.isEvonode { + await fetchClaimableBalance() + } + } + + /// Re-fetch the record: current list entry + Platform identities + + /// (once) the registration transaction. + func refresh() async { + guard let manager = SwiftDashSDKHost.shared.manager, !refreshing else { return } + refreshing = true + refreshError = nil + defer { refreshing = false } + do { + record = try await manager.refreshTrackedMasternode(proTxHash: record.proTxHash) + } catch { + refreshError = (error as? PlatformWalletError)?.errorDescription + ?? error.localizedDescription + } + } + + /// Same identity-balance fetch as the wallet masternode detail: the + /// identity id IS the proTxHash in display (reversed) order. + func fetchClaimableBalance() async { + guard let sdk = SwiftDashSDKHost.shared.sdk else { + balanceError = NSLocalizedString("Platform SDK not ready", comment: "Masternodes") + return + } + let identityId = Data(record.proTxHash.reversed()) + balanceLoading = true + balanceError = nil + do { + let credits = try await Task.detached(priority: .userInitiated) { + try sdk.identities.getBalance(id: identityId) + }.value + claimableCredits = credits + } catch { + claimableCredits = nil + let message = error.localizedDescription.lowercased() + if message.contains("not found") || message.contains("no identity") + || message.contains("does not exist") { + balanceError = NSLocalizedString("This masternode has no Platform identity yet.", comment: "Masternodes") + } else { + balanceError = NSLocalizedString("Couldn't fetch the balance (network error). Try Refresh.", comment: "Masternodes") + } + } + balanceLoading = false + } + + func setLabel(_ label: String) { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard let manager = SwiftDashSDKHost.shared.manager else { return } + try? manager.setTrackedMasternodeLabel( + proTxHash: record.proTxHash, + label: trimmed.isEmpty ? nil : trimmed) + // Re-read the record (local registry call) so the navigation title + // reflects the new label immediately, matching the list behind. + if let updated = manager.trackedMasternodes() + .first(where: { $0.proTxHash == record.proTxHash }) { + record = updated + } + } + + /// Untrack: registry row + every vault key. The keys belong to the app, + /// so they go when the tracking goes. + func stopTracking() -> Bool { + guard let manager = SwiftDashSDKHost.shared.manager else { return false } + do { + _ = try manager.untrackMasternode(proTxHash: record.proTxHash) + vault.removeAllKeys(for: record.proTxHash) + return true + } catch { + refreshError = (error as? PlatformWalletError)?.errorDescription + ?? error.localizedDescription + return false + } + } + + func removeKey(_ role: MasternodeKeyRole) { + vault.removeKey(for: record.proTxHash, role: role) + reloadAttachedRoles() + } + + // MARK: Withdraw + + /// 1 DASH = 1e8 duffs × `EvonodeWithdrawalViewModel.creditsPerDuff` + /// credits = 1e11 credits — the Platform protocol constant, kept out + /// of the Views per the repo guardrails. + private static let creditsPerDash = + Decimal(100_000_000) * Decimal(EvonodeWithdrawalViewModel.creditsPerDuff) + + static func creditsAsDash(_ credits: UInt64) -> String { + let dash = NSDecimalNumber(decimal: Decimal(credits) / creditsPerDash) + return String(format: "%.8f DASH", dash.doubleValue) + } + + /// Formatted balance / withdrawable amounts for the Views. + var claimableBalanceText: String? { + claimableCredits.map(Self.creditsAsDash) + } + + var maxWithdrawText: String { + Self.creditsAsDash(maxWithdrawCredits) + } + + /// Highest withdrawable amount: the balance minus the fee reserve the + /// identity keeps back for the transition fee. + var maxWithdrawCredits: UInt64 { + guard let credits = claimableCredits else { return 0 } + return credits > EvonodeWithdrawalViewModel.feeReserveCredits + ? credits - EvonodeWithdrawalViewModel.feeReserveCredits + : 0 + } + + var withdrawAmountCredits: UInt64? { + guard let dash = Decimal(string: withdrawAmountText.replacingOccurrences(of: ",", with: ".")), + dash > 0 else { return nil } + let credits = dash * Self.creditsPerDash + // `NSDecimalNumber.uint64Value` is undefined past UInt64.max — an + // absurd typed amount could WRAP to a small value that then passes + // the max-withdrawal check. Reject out-of-range input instead. + guard credits <= Decimal(UInt64.max) else { return nil } + return NSDecimalNumber(decimal: credits).uint64Value + } + + var canSubmitWithdrawal: Bool { + guard let amount = withdrawAmountCredits else { return false } + let destinationOK = withdrawalRole == .owner + || withdrawDestination.isEmpty + || withdrawDestination.isValidDashAddressForCurrentNetwork + return amount >= EvonodeWithdrawalViewModel.minWithdrawalCredits + && amount <= maxWithdrawCredits + && destinationOK + } + + /// Authenticate, read the signing key from the vault, and submit. The + /// key text goes straight to the SDK for this one call. + func submitWithdrawal() async -> Bool { + guard let manager = SwiftDashSDKHost.shared.manager, + let role = withdrawalRole, + let amount = withdrawAmountCredits else { return false } + withdrawError = nil + + let outcome = await AuthenticationGate.authenticate( + biometric: DWGlobalOptions.sharedInstance().biometricAuthEnabled) + guard outcome == .ok else { return false } + + guard let keyText = vault.key(for: record.proTxHash, role: role) else { + withdrawError = NSLocalizedString( + "The signing key is missing from the keychain — re-add it and try again.", + comment: "Tracked masternodes") + return false + } + + withdrawing = true + defer { withdrawing = false } + do { + let destination = withdrawDestination.trimmingCharacters(in: .whitespacesAndNewlines) + let remaining = try await manager.trackedMasternodeWithdraw( + proTxHash: record.proTxHash, + amountCredits: amount, + role: role, + key: keyText, + destinationAddress: (role == .ownerPayout && !destination.isEmpty) ? destination : nil) + claimableCredits = remaining + return true + } catch let error as PlatformWalletError { + if case .masternodeWithdrawalUnconfirmed = error { + // Ambiguous outcome: the nonce may be consumed. Drop the + // stale figure and re-read before any retry is possible. + claimableCredits = nil + Task { await fetchClaimableBalance() } + withdrawError = NSLocalizedString( + "The withdrawal was sent but its result couldn't be confirmed. The balance is being re-checked — don't retry until it settles.", + comment: "Tracked masternodes") + } else { + withdrawError = error.errorDescription + } + return false + } catch { + withdrawError = error.localizedDescription + return false + } + } +} + +// MARK: - TrackedMasternodeDetailScreen + +/// Detail for one tracked masternode: status and everything the SDK has +/// learned about it, the keys attached on this device (with add / remove), +/// and the actions those keys enable. +struct TrackedMasternodeDetailScreen: View { + @StateObject private var viewModel: TrackedMasternodeDetailViewModel + @Environment(\.dismiss) private var dismiss + @State private var labelDraft: String + @State private var showStopConfirm = false + @State private var showWithdrawSheet = false + @State private var showKeysSheet = false + let onChanged: () -> Void + + init(record: PlatformMasternode, onChanged: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: TrackedMasternodeDetailViewModel(record: record)) + _labelDraft = State(initialValue: record.label ?? "") + self.onChanged = onChanged + } + + var body: some View { + List { + overviewSection + registrationSection + keysSection + balanceSection + manageSection + } + .navigationTitle(viewModel.record.label ?? viewModel.record.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.load() } + .sheet(isPresented: $showWithdrawSheet) { + TrackedWithdrawalSheet(viewModel: viewModel) + } + .sheet(isPresented: $showKeysSheet, onDismiss: { viewModel.reloadAttachedRoles() }) { + TrackedKeyManagementSheet(record: viewModel.record, vault: viewModel.vault) + } + .confirmationDialog( + NSLocalizedString("Stop tracking this masternode?", comment: "Tracked masternodes"), + isPresented: $showStopConfirm, + titleVisibility: .visible + ) { + Button(NSLocalizedString("Stop tracking", comment: "Tracked masternodes"), role: .destructive) { + if viewModel.stopTracking() { + onChanged() + dismiss() + } + } + } message: { + Text(NSLocalizedString( + "Any keys you added for it are deleted from this device's keychain too.", + comment: "Tracked masternodes")) + } + } + + private var overviewSection: some View { + Section { + HStack { + Label(viewModel.record.typeName, systemImage: "server.rack") + .font(.headline) + Spacer() + Text(NSLocalizedString("Tracked", comment: "Tracked masternodes")) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.dash.blue.opacity(0.15)) + .foregroundColor(Color.dash.blue) + .clipShape(Capsule()) + Text(viewModel.record.statusName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(viewModel.record.statusColor) + } + if let service = viewModel.record.serviceAddress { + MasternodeDetailRow(label: NSLocalizedString("Service", comment: "Masternodes"), value: service) + } + TextField(NSLocalizedString("Label (optional)", comment: "Add masternode"), text: $labelDraft) + .onSubmit { + viewModel.setLabel(labelDraft) + onChanged() + } + } footer: { + if let error = viewModel.refreshError { + Text(error) + } + } + } + + private var registrationSection: some View { + Section(NSLocalizedString("Registration", comment: "Masternodes")) { + MasternodeCopyRow(label: "proTxHash", value: viewModel.record.proTxHashHex) + if viewModel.record.hasRegistration { + MasternodeDetailRow( + label: NSLocalizedString("Registered at", comment: "Masternodes"), + value: String( + format: NSLocalizedString("Height %u", comment: "Masternodes"), + viewModel.record.registrationHeight)) + } + if let collateral = viewModel.record.collateralDisplay { + MasternodeCopyRow(label: NSLocalizedString("Outpoint", comment: "Masternodes"), value: collateral) + } + if let owner = viewModel.record.ownerAddress { + MasternodeCopyRow(label: NSLocalizedString("Owner address", comment: "Masternodes"), value: owner) + } + if let voting = viewModel.record.votingAddress { + MasternodeCopyRow(label: NSLocalizedString("Voting address", comment: "Masternodes"), value: voting) + } + if let operatorKey = viewModel.record.operatorPublicKeyHex { + MasternodeCopyRow(label: NSLocalizedString("Operator public key (BLS)", comment: "Masternodes"), value: operatorKey) + } + if let nodeId = viewModel.record.platformNodeIdHex { + MasternodeCopyRow(label: NSLocalizedString("Platform Node ID", comment: ""), value: nodeId) + } + if let payout = viewModel.record.payoutAddress { + MasternodeCopyRow(label: NSLocalizedString("Payout address", comment: "Masternodes"), value: payout) + } + } + } + + private var keysSection: some View { + Section { + ForEach(TrackedMasternodeKeyVault.managedRoles, id: \.rawValue) { role in + HStack { + Text(role.displayName) + .font(.subheadline) + Spacer() + if viewModel.attachedRoles.contains(role) { + Label(NSLocalizedString("On this device", comment: "Tracked masternodes"), systemImage: "key.fill") + .font(.caption) + .foregroundColor(.green) + Button(role: .destructive) { + viewModel.removeKey(role) + } label: { + Image(systemName: "trash") + .font(.caption) + } + .buttonStyle(.borderless) + } else { + Text(NSLocalizedString("Not added", comment: "Tracked masternodes")) + .font(.caption) + .foregroundColor(Color.dash.tertiaryText) + } + } + } + Button { + showKeysSheet = true + } label: { + Label(NSLocalizedString("Add keys", comment: "Tracked masternodes"), systemImage: "plus") + } + } header: { + Text(NSLocalizedString("Keys on this device", comment: "Tracked masternodes")) + } footer: { + Text(NSLocalizedString( + "Keys are stored in this device's keychain and never leave it except to sign the action you request.", + comment: "Tracked masternodes")) + } + } + + @ViewBuilder + private var balanceSection: some View { + if viewModel.record.isEvonode { + Section(NSLocalizedString("Claimable balance", comment: "Masternodes")) { + if viewModel.balanceLoading { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Fetching…", comment: "Masternodes")) + .foregroundColor(Color.dash.secondaryText) + } + } else if let balance = viewModel.claimableBalanceText { + MasternodeDetailRow( + label: NSLocalizedString("Balance", comment: "Masternodes"), + value: balance) + } else if let error = viewModel.balanceError { + Text(error) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + + Button { + Task { await viewModel.fetchClaimableBalance() } + } label: { + Label(NSLocalizedString("Refresh", comment: ""), systemImage: "arrow.clockwise") + } + .disabled(viewModel.balanceLoading) + + if let credits = viewModel.claimableCredits, credits > 0 { + if viewModel.withdrawalRole != nil { + Button { + showWithdrawSheet = true + } label: { + Label(NSLocalizedString("Withdraw", comment: "Evonode withdrawal"), systemImage: "arrow.down.circle") + .foregroundColor(Color.dash.blue) + } + } else { + Text(NSLocalizedString( + "Add this node's owner key or payout address key to withdraw from here.", + comment: "Tracked masternodes")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + } + } + } + + private var manageSection: some View { + Section { + requestStatusRow + + Button { + Task { + await viewModel.refresh() + onChanged() + } + } label: { + if viewModel.refreshing { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Refreshing…", comment: "Tracked masternodes")) + } + } else { + Label(NSLocalizedString("Refresh details", comment: "Tracked masternodes"), systemImage: "arrow.clockwise") + } + } + .disabled(viewModel.refreshing) + + Button(role: .destructive) { + showStopConfirm = true + } label: { + Label(NSLocalizedString("Stop tracking", comment: "Tracked masternodes"), systemImage: "eye.slash") + .foregroundColor(.red) + } + } + } + + /// Ask the node itself for its DAPI status — the same + /// `EvonodeStatusScreen` the wallet-owned detail pushes, keyed off the + /// record's DAPI address (evonodes with a routable service address). + @ViewBuilder + private var requestStatusRow: some View { + if viewModel.record.platformDAPIAddress != nil { + NavigationLink { + EvonodeStatusScreen(masternode: viewModel.record) + } label: { + Label(NSLocalizedString("Request status", comment: "Evonode status"), systemImage: "antenna.radiowaves.left.and.right") + .foregroundColor(Color.dash.blue) + } + } + } +} + +// MARK: - TrackedWithdrawalSheet + +/// Compact withdrawal form for a tracked evonode: amount, destination (when +/// signing with the payout key), authenticate, submit. The wallet-scoped +/// flow keeps its richer screen; this one signs with a vault key via +/// `trackedMasternodeWithdraw`. +private struct TrackedWithdrawalSheet: View { + @ObservedObject var viewModel: TrackedMasternodeDetailViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + MasternodeDetailRow( + label: NSLocalizedString("Available", comment: "Evonode withdrawal"), + value: viewModel.maxWithdrawText) + TextField( + NSLocalizedString("Amount (DASH)", comment: "Evonode withdrawal"), + text: $viewModel.withdrawAmountText) + .keyboardType(.decimalPad) + if viewModel.withdrawalRole == .ownerPayout { + TextField( + NSLocalizedString("Destination address (optional)", comment: "Tracked masternodes"), + text: $viewModel.withdrawDestination) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } footer: { + Text(viewModel.withdrawalRole == .owner + ? NSLocalizedString( + "Signing with the owner key — the withdrawal goes to the registered payout address.", + comment: "Tracked masternodes") + : NSLocalizedString( + "Signing with the payout address key. Leave the destination empty to withdraw to the payout address.", + comment: "Tracked masternodes")) + } + + Section { + Button { + Task { + if await viewModel.submitWithdrawal() { + dismiss() + } + } + } label: { + if viewModel.withdrawing { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Withdrawing…", comment: "Tracked masternodes")) + } + .frame(maxWidth: .infinity) + } else { + Text(NSLocalizedString("Withdraw", comment: "Evonode withdrawal")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + } + .disabled(!viewModel.canSubmitWithdrawal || viewModel.withdrawing) + } footer: { + if let error = viewModel.withdrawError { + Text(error).foregroundColor(.red) + } + } + } + .navigationTitle(NSLocalizedString("Withdraw", comment: "Evonode withdrawal")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("Cancel", comment: "")) { dismiss() } + } + } + } + } +} + +// MARK: - TrackedKeyManagementSheet + +/// Add / replace keys for an already-tracked node — the same verified key +/// fields as the add flow, seeded from the tracked record. +private struct TrackedKeyManagementSheet: View { + @StateObject private var viewModel: AddMasternodeViewModel + @Environment(\.dismiss) private var dismiss + + init(record: PlatformMasternode, vault: TrackedMasternodeKeyVaulting) { + let model = AddMasternodeViewModel(vault: vault) + model.adoptTrackedRecord(record) + _viewModel = StateObject(wrappedValue: model) + } + + var body: some View { + NavigationStack { + List { + TrackedKeyFieldsSection(viewModel: viewModel) + Section { + Button { + if viewModel.saveKeys() { + dismiss() + } + } label: { + Text(NSLocalizedString("Save keys", comment: "Tracked masternodes")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + .disabled(!viewModel.canSaveKeys) + } footer: { + if !viewModel.canSaveKeys { + Text(NSLocalizedString( + "A key doesn't match this masternode — fix or clear it to continue.", + comment: "Add masternode")) + .foregroundColor(.red) + } + } + } + .navigationTitle(NSLocalizedString("Add keys", comment: "Tracked masternodes")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("Cancel", comment: "")) { dismiss() } + } + } + } + } +} diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index fbff0c1aa..ea3b5857d 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5627,6 +5627,107 @@ "Nodes, epoch day %d, %@ blocks proposed this epoch" = "Nodes, epoch day %d, %@ blocks proposed this epoch"; "an evonode has not proposed a block in 2 days" = "an evonode has not proposed a block in 2 days"; "an evonode has no blocks this epoch" = "an evonode has no blocks this epoch"; + +"A key doesn't match this masternode — fix or clear it to continue." = "A key doesn't match this masternode — fix or clear it to continue."; + +"Add keys" = "Add keys"; + +"Add masternode" = "Add masternode"; + +"Add this node's owner key or payout address key to withdraw from here." = "Add this node's owner key or payout address key to withdraw from here."; + +"Already one of this wallet's masternodes — it's in the list above." = "Already one of this wallet's masternodes — it's in the list above."; + +"Already tracked." = "Already tracked."; + +"Any keys you added for it are deleted from this device's keychain too." = "Any keys you added for it are deleted from this device's keychain too."; + +"Available" = "Available"; + +"BLS private key (hex)" = "BLS private key (hex)"; + +"Can't verify yet" = "Can't verify yet"; + +"Could not load the node's registration details — owner and payout keys will verify later." = "Could not load the node's registration details — owner and payout keys will verify later."; + +"Could not save a key to the keychain. Nothing was lost — try again." = "Could not save a key to the keychain. Nothing was lost — try again."; + +"Destination address (optional)" = "Destination address (optional)"; + +"Doesn't match" = "Doesn't match"; + +"Find" = "Find"; + +"Finds owner and payout keys, which aren't in the masternode list. Sends the key's public fingerprint to a Platform node." = "Finds owner and payout keys, which aren't in the masternode list. Sends the key's public fingerprint to a Platform node."; + +"IP address, proTxHash or private key" = "IP address, proTxHash or private key"; + +"Keys are stored in this device's keychain and never leave it except to sign the action you request." = "Keys are stored in this device's keychain and never leave it except to sign the action you request."; + +"Keys on this device" = "Keys on this device"; + +"Label (optional)" = "Label (optional)"; + +"Loading registration details…" = "Loading registration details…"; + +"Matches" = "Matches"; + +"Matches this node's %@ key" = "Matches this node's %@ key"; + +"No masternode on the list matches that." = "No masternode on the list matches that."; + +"Node key (base64 or hex)" = "Node key (base64 or hex)"; + +"Not added" = "Not added"; + +"On this device" = "On this device"; + +"Operator payout" = "Operator payout"; + +"Payout" = "Payout"; + +"Platform could not be reached, so owner and payout keys were not checked." = "Platform could not be reached, so owner and payout keys were not checked."; + +"PoSe banned" = "PoSe banned"; + +"Private key (WIF or hex)" = "Private key (WIF or hex)"; + +"Refresh details" = "Refresh details"; + +"Refreshing…" = "Refreshing…"; + +"Save keys" = "Save keys"; + +"Search Platform too" = "Search Platform too"; + +"Searching…" = "Searching…"; + +"Signing with the owner key — the withdrawal goes to the registered payout address." = "Signing with the owner key — the withdrawal goes to the registered payout address."; + +"Signing with the payout address key. Leave the destination empty to withdraw to the payout address." = "Signing with the payout address key. Leave the destination empty to withdraw to the payout address."; + +"Stop tracking" = "Stop tracking"; + +"Stop tracking this masternode?" = "Stop tracking this masternode?"; + +"That could also be an owner or payout key — turn on “Search Platform too” to check." = "That could also be an owner or payout key — turn on “Search Platform too” to check."; + +"The signing key is missing from the keychain — re-add it and try again." = "The signing key is missing from the keychain — re-add it and try again."; + +"The withdrawal was sent but its result couldn't be confirmed. The balance is being re-checked — don't retry until it settles." = "The withdrawal was sent but its result couldn't be confirmed. The balance is being re-checked — don't retry until it settles."; + +"Track any masternode or evonode on the network — it doesn't have to belong to this wallet." = "Track any masternode or evonode on the network — it doesn't have to belong to this wallet."; + +"Track this masternode" = "Track this masternode"; + +"Tracked" = "Tracked"; + +"Tracked. Add this node's keys below to enable actions like withdrawing and voting, or finish now and add them later." = "Tracked. Add this node's keys below to enable actions like withdrawing and voting, or finish now and add them later."; + +"Withdrawing…" = "Withdrawing…"; + +"You can also track any masternode on the network — by IP, proTxHash, or one of its keys — with the + button." = "You can also track any masternode on the network — by IP, proTxHash, or one of its keys — with the + button."; + "This evonode's DAPI address isn't known, so it can't be asked for its status." = "This evonode's DAPI address isn't known, so it can't be asked for its status."; "Couldn't get the evonode's status." = "Couldn't get the evonode's status."; "The evonode didn't answer in time." = "The evonode didn't answer in time.";