diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 919db1e3a..f01200fe1 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -620,6 +620,10 @@ 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 */; }; + 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 */; }; + 5AE5700E2F9C000000000006 /* EvonodeStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */; }; 51BA2EA02F0B2E0100A1B201 /* SwapPendingGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BA2E9F2F0B2E0100A1B201 /* SwapPendingGate.swift */; }; 51BA2EA12F0B2E0100A1B201 /* SwapPendingGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BA2E9F2F0B2E0100A1B201 /* SwapPendingGate.swift */; }; 51F0D10130BF97A900B27E11 /* ExclamationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F0D10030BF97A900B27E11 /* ExclamationIcon.swift */; }; @@ -2815,6 +2819,8 @@ 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 = ""; }; + 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 = ""; }; 51DFA7F72FA8A9DF001EDB3A /* DashStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashStepper.swift; sourceTree = ""; }; 51F0D10030BF97A900B27E11 /* ExclamationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExclamationIcon.swift; sourceTree = ""; }; @@ -3783,6 +3789,15 @@ path = "Masternode Withdrawal"; sourceTree = ""; }; + 5AE5700E2F9C000000000007 /* Evonode Status */ = { + isa = PBXGroup; + children = ( + 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */, + 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */, + ); + path = "Evonode Status"; + sourceTree = ""; + }; 0529717581A09721B3115957 /* InternalTransfer */ = { isa = PBXGroup; children = ( @@ -4826,6 +4841,7 @@ FB3FF0B0222641210059A9A5 /* Masternode Keys */, 51AA00202F970020005A0020 /* MasternodesScreen.swift */, 5AEDE0012F9B000000000007 /* Masternode Withdrawal */, + 5AE5700E2F9C000000000007 /* Evonode Status */, 7566F4822BB69498005238D2 /* ToolsMenuScreen.swift */, RC00C0032FA0000000000001 /* CSVExportSheet.swift */, 752F81A82E323FD000ADA76D /* ToolsMenuViewModel.swift */, @@ -10208,6 +10224,8 @@ 51AA00222F970022005A0022 /* MasternodesScreen.swift in Sources */, 5AEDE0012F9B000000000004 /* EvonodeWithdrawalViewModel.swift in Sources */, 5AEDE0012F9B000000000006 /* EvonodeWithdrawalScreen.swift in Sources */, + 5AE5700E2F9C000000000004 /* EvonodeStatusViewModel.swift in Sources */, + 5AE5700E2F9C000000000006 /* EvonodeStatusScreen.swift in Sources */, 51AA00022F970002005A0002 /* SyncInfoMenuScreen.swift in Sources */, 51AA00052F970005005A0005 /* SyncInfoMenuViewModel.swift in Sources */, 51AA00082F970008005A0008 /* DashPaySyncInfoScreen.swift in Sources */, @@ -11222,6 +11240,8 @@ 51AA00212F970021005A0021 /* MasternodesScreen.swift in Sources */, 5AEDE0012F9B000000000003 /* EvonodeWithdrawalViewModel.swift in Sources */, 5AEDE0012F9B000000000005 /* EvonodeWithdrawalScreen.swift in Sources */, + 5AE5700E2F9C000000000003 /* EvonodeStatusViewModel.swift in Sources */, + 5AE5700E2F9C000000000005 /* EvonodeStatusScreen.swift in Sources */, 51AA00032F970003005A0003 /* SyncInfoMenuScreen.swift in Sources */, 51AA00062F970006005A0006 /* SyncInfoMenuViewModel.swift in Sources */, 51AA00092F970009005A0009 /* DashPaySyncInfoScreen.swift in Sources */, diff --git a/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusScreen.swift new file mode 100644 index 000000000..2fa1dc8d0 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusScreen.swift @@ -0,0 +1,167 @@ +// +// EvonodeStatusScreen.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftDashSDK +import SwiftUI + +// MARK: - EvonodeStatusScreen + +/// Everything one evonode answered to a DAPI `getStatus` request, grouped +/// as the node groups it. Pushed from the evonode detail screen's "Request +/// status" button; the request itself is sent when this screen appears and +/// again on Refresh — never on its own. +struct EvonodeStatusScreen: View { + @StateObject private var viewModel: EvonodeStatusViewModel + + init(masternode: PlatformMasternode) { + _viewModel = StateObject(wrappedValue: EvonodeStatusViewModel(masternode: masternode)) + } + + var body: some View { + content + .navigationTitle(NSLocalizedString("Node status", comment: "Evonode status")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { await viewModel.request() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(viewModel.isLoading) + .accessibilityLabel(NSLocalizedString("Refresh", comment: "")) + } + } + .task { + // The user reached this screen by tapping "Request status" — + // that tap is the request. Re-appearing (back from a pushed + // screen) keeps the last answer; Refresh asks again. + if viewModel.phase == .idle { + await viewModel.request() + } + } + } + + // MARK: Bodies + + /// A refresh keeps the previous answer on screen (with a small spinner) + /// instead of blanking it; only the first request shows the full loader. + @ViewBuilder + private var content: some View { + switch viewModel.phase { + case .idle: + loadingBody + case .loading: + if viewModel.sections.isEmpty { + loadingBody + } else { + report + } + case .failed(let title, let detail): + failedBody(title: title, detail: detail) + case .loaded: + report + } + } + + private var loadingBody: some View { + VStack(spacing: 12) { + // Explicitly SwiftUI's — the app declares its own `ProgressView` + // UIKit class that shadows it here. + SwiftUI.ProgressView() + Text(NSLocalizedString("Asking the evonode…", comment: "Evonode status")) + .font(.subheadline) + .foregroundColor(Color.dash.secondaryText) + if let address = viewModel.address { + Text(address) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(Color.dash.tertiaryText) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func failedBody(title: String, detail: String?) -> some View { + VStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 40)) + .foregroundColor(Color.dash.tertiaryText) + + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + + if let address = viewModel.address { + Text(address) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(Color.dash.tertiaryText) + } + + if let detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + .multilineTextAlignment(.center) + .textSelection(.enabled) + } + + if viewModel.address != nil { + DashButton( + text: NSLocalizedString("Try again", comment: ""), + style: .filled, + stretch: false, + action: { Task { await viewModel.request() } }) + .padding(.top, 8) + } + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var report: some View { + List { + ForEach(viewModel.sections) { section in + Section { + ForEach(section.rows) { row in + switch row.style { + case .value: + MasternodeDetailRow(label: row.label, value: row.value) + case .copyable: + MasternodeCopyRow(label: row.label, value: row.value) + } + } + } header: { + Text(section.title) + } footer: { + if let footer = section.footer { + Text(footer) + } + } + } + } + .overlay(alignment: .top) { + if viewModel.isLoading { + SwiftUI.ProgressView() + .padding(8) + .background(.thinMaterial, in: Capsule()) + .padding(.top, 8) + } + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusViewModel.swift new file mode 100644 index 000000000..b4319ab7b --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Evonode Status/EvonodeStatusViewModel.swift @@ -0,0 +1,349 @@ +// +// EvonodeStatusViewModel.swift +// DashWallet +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftDashSDK + +// MARK: - Display model + +/// One label/value line of the status report. +struct EvonodeStatusRow: Identifiable, Equatable { + enum Style: Equatable { + /// Short value, trailing-aligned. + case value + /// Long hex / URI — monospaced block, tap to copy. + case copyable + } + + let id: String + let label: String + let value: String + let style: Style +} + +/// One titled group of rows, in the node's own grouping (version, node, +/// chain, network, state sync, time). +struct EvonodeStatusSection: Identifiable, Equatable { + let id: String + let title: String + let footer: String? + let rows: [EvonodeStatusRow] +} + +// MARK: - EvonodeStatusViewModel + +/// Asks ONE evonode for its DAPI `getStatus` self-report and lays out every +/// field it returned for `EvonodeStatusScreen`. +/// +/// Nothing is requested until `request()` runs — the screen is reached only +/// through an explicit "Request status" tap, and the request goes to that +/// node alone (`PlatformMasternode.platformDAPIAddress`), not through the +/// SDK's address list. The report is unproved by nature: it is the node +/// describing itself, so it is shown as diagnostics, never folded into +/// wallet state. +@MainActor +final class EvonodeStatusViewModel: ObservableObject { + enum Phase: Equatable { + case idle + case loading + case loaded + /// `detail` is the underlying SDK / transport message, shown small + /// under the headline — this is a diagnostics screen. + case failed(title: String, detail: String?) + } + + let masternode: PlatformMasternode + /// `https://:` the request is sent to, or `nil` + /// when the aggregation doesn't know the node's service address or + /// platform port (then `request()` fails without touching the network). + let address: String? + + @Published private(set) var phase: Phase = .idle + @Published private(set) var sections: [EvonodeStatusSection] = [] + @Published private(set) var receivedAt: Date? + + init(masternode: PlatformMasternode) { + self.masternode = masternode + address = masternode.platformDAPIAddress + } + + var isLoading: Bool { phase == .loading } + + /// Send the request (or send it again). The FFI call is blocking — it + /// runs off the main actor. + func request() async { + guard phase != .loading else { return } + guard let address else { + phase = .failed( + title: NSLocalizedString("This evonode's DAPI address isn't known, so it can't be asked for its status.", comment: "Evonode status"), + detail: nil) + return + } + guard let sdk = SwiftDashSDKHost.shared.sdk else { + phase = .failed(title: NSLocalizedString("Platform SDK not ready", comment: "Masternodes"), detail: nil) + return + } + + phase = .loading + do { + let status = try await Task.detached(priority: .userInitiated) { + try sdk.getEvonodeStatus(address: address) + }.value + let now = Date() + receivedAt = now + sections = Self.sections(for: status, masternode: masternode, address: address, receivedAt: now) + phase = .loaded + } catch { + phase = Self.failure(for: error) + } + } + + // MARK: Failure copy + + private static func failure(for error: Error) -> Phase { + guard let sdkError = error as? SDKError else { + return .failed( + title: NSLocalizedString("Couldn't get the evonode's status.", comment: "Evonode status"), + detail: error.localizedDescription) + } + switch sdkError { + case .timeout(let detail): + return .failed( + title: NSLocalizedString("The evonode didn't answer in time.", comment: "Evonode status"), + detail: detail) + case .networkError(let detail): + return .failed( + title: NSLocalizedString("Couldn't reach the evonode.", comment: "Evonode status"), + detail: detail) + case .serializationError(let detail): + return .failed( + title: NSLocalizedString("The evonode's answer couldn't be read.", comment: "Evonode status"), + detail: detail) + case .invalidParameter(let detail), .invalidState(let detail), .protocolError(let detail), + .cryptoError(let detail), .notFound(let detail), .notImplemented(let detail), + .internalError(let detail), .unknown(let detail): + return .failed( + title: NSLocalizedString("Couldn't get the evonode's status.", comment: "Evonode status"), + detail: detail) + } + } + + // MARK: Layout + + /// Every field of the report, grouped as the node groups them. Optional + /// fields the node omitted read "Not reported" — never a zero that looks + /// like data. + static func sections( + for status: EvonodeStatus, + masternode: PlatformMasternode, + address: String, + receivedAt: Date + ) -> [EvonodeStatusSection] { + var sections: [EvonodeStatusSection] = [] + + sections.append(EvonodeStatusSection( + id: "request", + title: NSLocalizedString("Request", comment: "Evonode status"), + footer: NSLocalizedString("The node's own report, unverified — what it says about itself, not what the network has agreed on.", comment: "Evonode status"), + rows: [ + EvonodeStatusRow(id: "address", label: NSLocalizedString("Asked", comment: "Evonode status: the DAPI address the request went to"), value: address, style: .copyable), + EvonodeStatusRow(id: "received", label: NSLocalizedString("Received", comment: "Evonode status"), value: dateTime(receivedAt), style: .value), + ])) + + // Software versions + let software = status.version.software + sections.append(EvonodeStatusSection( + id: "software", + title: NSLocalizedString("Software", comment: "Evonode status"), + footer: nil, + rows: [ + valueRow("dapi", NSLocalizedString("DAPI", comment: "Evonode status"), software?.dapi), + valueRow("drive", NSLocalizedString("Drive", comment: "Evonode status"), software?.drive), + valueRow("tenderdash", NSLocalizedString("Tenderdash", comment: "Evonode status"), software?.tenderdash), + ])) + + // Protocol versions + let protocolVersions = status.version.protocol + sections.append(EvonodeStatusSection( + id: "protocol", + title: NSLocalizedString("Protocol versions", comment: "Evonode status"), + footer: nil, + rows: [ + valueRow("td-p2p", NSLocalizedString("Tenderdash P2P", comment: "Evonode status"), protocolVersions?.tenderdash.map { number($0.p2p) }), + valueRow("td-block", NSLocalizedString("Tenderdash block", comment: "Evonode status"), protocolVersions?.tenderdash.map { number($0.block) }), + valueRow("drive-current", NSLocalizedString("Drive current", comment: "Evonode status"), protocolVersions?.drive.map { number($0.current) }), + valueRow("drive-latest", NSLocalizedString("Drive latest", comment: "Evonode status"), protocolVersions?.drive.map { number($0.latest) }), + valueRow("drive-next", NSLocalizedString("Drive next epoch", comment: "Evonode status"), protocolVersions?.drive.map { number($0.nextEpoch) }), + ])) + + // Node + sections.append(EvonodeStatusSection( + id: "node", + title: NSLocalizedString("Node", comment: "Evonode status"), + footer: nil, + rows: [ + hexRow("node-id", NSLocalizedString("Node ID", comment: "Evonode status"), status.node.id), + hexRow("protx", "proTxHash", status.node.proTxHash), + EvonodeStatusRow( + id: "identity", + label: NSLocalizedString("Identity check", comment: "Evonode status"), + value: identityCheck(reported: status.node.proTxHash, masternode: masternode), + style: .value), + ])) + + // Chain + let chain = status.chain + sections.append(EvonodeStatusSection( + id: "chain", + title: NSLocalizedString("Chain", comment: "Evonode status"), + footer: nil, + rows: [ + EvonodeStatusRow(id: "catching-up", label: NSLocalizedString("Catching up", comment: "Evonode status"), value: yesNo(chain.catchingUp), style: .value), + EvonodeStatusRow(id: "latest-height", label: NSLocalizedString("Latest block height", comment: "Evonode status"), value: number(chain.latestBlockHeight), style: .value), + EvonodeStatusRow(id: "earliest-height", label: NSLocalizedString("Earliest block height", comment: "Evonode status"), value: number(chain.earliestBlockHeight), style: .value), + EvonodeStatusRow(id: "max-peer-height", label: NSLocalizedString("Max peer block height", comment: "Evonode status"), value: number(chain.maxPeerBlockHeight), style: .value), + valueRow("core-locked", NSLocalizedString("Core chain-locked height", comment: "Evonode status"), chain.coreChainLockedHeight.map { number($0) }), + hexRow("latest-block-hash", NSLocalizedString("Latest block hash", comment: "Evonode status"), chain.latestBlockHash), + hexRow("latest-app-hash", NSLocalizedString("Latest app hash", comment: "Evonode status"), chain.latestAppHash), + hexRow("earliest-block-hash", NSLocalizedString("Earliest block hash", comment: "Evonode status"), chain.earliestBlockHash), + hexRow("earliest-app-hash", NSLocalizedString("Earliest app hash", comment: "Evonode status"), chain.earliestAppHash), + ])) + + // Network + let network = status.network + sections.append(EvonodeStatusSection( + id: "network", + title: NSLocalizedString("Network", comment: "Evonode status"), + footer: nil, + rows: [ + valueRow("chain-id", NSLocalizedString("Chain ID", comment: "Evonode status"), network.chainId.isEmpty ? nil : network.chainId), + EvonodeStatusRow(id: "peers", label: NSLocalizedString("Peers", comment: "Evonode status"), value: number(network.peersCount), style: .value), + EvonodeStatusRow(id: "listening", label: NSLocalizedString("Listening", comment: "Evonode status"), value: yesNo(network.listening), style: .value), + ])) + + // State sync + let sync = status.stateSync + sections.append(EvonodeStatusSection( + id: "state-sync", + title: NSLocalizedString("State sync", comment: "Evonode status"), + footer: nil, + rows: [ + EvonodeStatusRow(id: "synced-time", label: NSLocalizedString("Total synced time", comment: "Evonode status"), value: nanoseconds(sync.totalSyncedTime), style: .value), + EvonodeStatusRow(id: "remaining-time", label: NSLocalizedString("Remaining time", comment: "Evonode status"), value: nanoseconds(sync.remainingTime), style: .value), + EvonodeStatusRow(id: "snapshots", label: NSLocalizedString("Total snapshots", comment: "Evonode status"), value: number(sync.totalSnapshots), style: .value), + EvonodeStatusRow(id: "chunk-avg", label: NSLocalizedString("Chunk process avg time", comment: "Evonode status"), value: nanoseconds(sync.chunkProcessAvgTime), style: .value), + EvonodeStatusRow(id: "snapshot-height", label: NSLocalizedString("Snapshot height", comment: "Evonode status"), value: number(sync.snapshotHeight), style: .value), + EvonodeStatusRow(id: "snapshot-chunks", label: NSLocalizedString("Snapshot chunks", comment: "Evonode status"), value: number(sync.snapshotChunksCount), style: .value), + EvonodeStatusRow(id: "backfilled", label: NSLocalizedString("Backfilled blocks", comment: "Evonode status"), value: number(sync.backfilledBlocks), style: .value), + EvonodeStatusRow(id: "backfill-total", label: NSLocalizedString("Backfill blocks total", comment: "Evonode status"), value: number(sync.backfillBlocksTotal), style: .value), + ])) + + // Time — the SDK's `*Date` accessors resolve the per-field units + // (and Drive's `0` = unknown) so these never show 1970. + let time = status.time + sections.append(EvonodeStatusSection( + id: "time", + title: NSLocalizedString("Time", comment: "Evonode status"), + footer: nil, + rows: [ + valueRow("local", NSLocalizedString("Node clock", comment: "Evonode status"), time.localDate.map(dateTime)), + valueRow("block-time", NSLocalizedString("Latest block", comment: "Evonode status"), time.blockDate.map(dateTime)), + valueRow("genesis", NSLocalizedString("Genesis", comment: "Evonode status"), time.genesisDate.map(dateTime)), + valueRow("epoch", NSLocalizedString("Epoch", comment: "Evonode status"), time.epoch.map { number($0) }), + ])) + + return sections + } + + /// "Matches this evonode" when the node reports the proTxHash of the + /// masternode being viewed — in either byte order, since the app stores + /// wire order and explorers show it reversed — "differs" when it names + /// another masternode (the IP now serves a different node), and "Not + /// reported" for a full node / missing field. + private static func identityCheck(reported: String?, masternode: PlatformMasternode) -> String { + guard let reported, !reported.isEmpty else { return notReported } + let lowered = reported.lowercased() + let wireHex = masternode.proTxHash.map { String(format: "%02x", $0) }.joined() + if lowered == masternode.proTxHashHex || lowered == wireHex { + return NSLocalizedString("Matches this evonode", comment: "Evonode status") + } + return NSLocalizedString("Differs from this evonode", comment: "Evonode status") + } + + // MARK: Formatting + + static let notReported = NSLocalizedString("Not reported", comment: "Evonode status: the node omitted this field") + + private static func valueRow(_ id: String, _ label: String, _ value: String?) -> EvonodeStatusRow { + EvonodeStatusRow(id: id, label: label, value: value ?? notReported, style: .value) + } + + /// Hex hashes/ids are copyable blocks; an empty / absent one is a plain + /// "Not reported" line. + private static func hexRow(_ id: String, _ label: String, _ hex: String?) -> EvonodeStatusRow { + guard let hex, !hex.isEmpty else { + return EvonodeStatusRow(id: id, label: label, value: notReported, style: .value) + } + return EvonodeStatusRow(id: id, label: label, value: hex, style: .copyable) + } + + private static let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private static func number(_ value: N) -> String { + numberFormatter.string(from: NSNumber(value: Int64(clamping: value))) ?? "\(value)" + } + + private static func yesNo(_ value: Bool) -> String { + value ? NSLocalizedString("Yes", comment: "") : NSLocalizedString("No", comment: "") + } + + private static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() + + private static func dateTime(_ date: Date) -> String { + dateTimeFormatter.string(from: date) + } + + private static let durationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 3 + return formatter + }() + + /// Tenderdash state-sync timings are Go `time.Duration`s — integer + /// nanoseconds on the wire. + private static func nanoseconds(_ value: UInt64) -> String { + guard value > 0 else { return number(0) } + let seconds = TimeInterval(value) / 1_000_000_000 + if seconds < 1 { + return String(format: NSLocalizedString("%.3f s", comment: "Evonode status: sub-second duration"), seconds) + } + return durationFormatter.string(from: seconds) ?? String(format: "%.0f s", seconds) + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift index 01c2f3bae..ef5fbbf28 100644 --- a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift @@ -513,6 +513,9 @@ struct MasternodeDetailScreen: View { if let service = masternode.serviceAddress { MasternodeDetailRow(label: NSLocalizedString("Service", comment: "Masternodes"), value: service) } + if masternode.isEvonode { + requestStatusRow + } } Section(NSLocalizedString("Registration", comment: "Masternodes")) { @@ -633,6 +636,28 @@ struct MasternodeDetailScreen: View { } } + /// Entry point to the node's DAPI `getStatus` self-report + /// (`EvonodeStatusScreen`). Nothing is requested until the user taps — + /// the pushed screen sends the request. Disabled, with the reason, when + /// the aggregation doesn't know the node's DAPI address. + @ViewBuilder + private var requestStatusRow: some View { + if masternode.platformDAPIAddress != nil { + NavigationLink { + EvonodeStatusScreen(masternode: masternode) + } label: { + Label(NSLocalizedString("Request status", comment: "Evonode status"), systemImage: "antenna.radiowaves.left.and.right") + .foregroundColor(Color.dash.blue) + } + } else { + Label(NSLocalizedString("Request status", comment: "Evonode status"), systemImage: "antenna.radiowaves.left.and.right") + .foregroundColor(Color.dash.tertiaryText) + Text(NSLocalizedString("This evonode's DAPI address isn't known, so it can't be asked for its status.", comment: "Evonode status")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + /// Withdraw entry point + the one-line reason it is / isn't available. /// Shown only once the balance is known and positive. @ViewBuilder @@ -687,8 +712,8 @@ struct MasternodeDetailScreen: View { // MARK: - Rows -/// Label + trailing value row. -private struct MasternodeDetailRow: View { +/// Label + trailing value row. Internal: `EvonodeStatusScreen` reuses it. +struct MasternodeDetailRow: View { let label: String let value: String @@ -707,7 +732,8 @@ private struct MasternodeDetailRow: View { } /// Caption + monospaced, tap-to-copy value block for hashes / addresses. -private struct MasternodeCopyRow: View { +/// Internal: `EvonodeStatusScreen` reuses it. +struct MasternodeCopyRow: View { let label: String let value: String @State private var copied = false diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 15ce96108..fbff0c1aa 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5627,3 +5627,56 @@ "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"; +"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."; +"Couldn't reach the evonode." = "Couldn't reach the evonode."; +"The evonode's answer couldn't be read." = "The evonode's answer couldn't be read."; +"The node's own report, unverified — what it says about itself, not what the network has agreed on." = "The node's own report, unverified — what it says about itself, not what the network has agreed on."; +"Asked" = "Asked"; +"Software" = "Software"; +"DAPI" = "DAPI"; +"Drive" = "Drive"; +"Tenderdash" = "Tenderdash"; +"Protocol versions" = "Protocol versions"; +"Tenderdash P2P" = "Tenderdash P2P"; +"Tenderdash block" = "Tenderdash block"; +"Drive current" = "Drive current"; +"Drive latest" = "Drive latest"; +"Drive next epoch" = "Drive next epoch"; +"Node ID" = "Node ID"; +"Identity check" = "Identity check"; +"Chain" = "Chain"; +"Catching up" = "Catching up"; +"Latest block height" = "Latest block height"; +"Earliest block height" = "Earliest block height"; +"Max peer block height" = "Max peer block height"; +"Core chain-locked height" = "Core chain-locked height"; +"Latest block hash" = "Latest block hash"; +"Latest app hash" = "Latest app hash"; +"Earliest block hash" = "Earliest block hash"; +"Earliest app hash" = "Earliest app hash"; +"Chain ID" = "Chain ID"; +"Peers" = "Peers"; +"Listening" = "Listening"; +"State sync" = "State sync"; +"Total synced time" = "Total synced time"; +"Remaining time" = "Remaining time"; +"Total snapshots" = "Total snapshots"; +"Chunk process avg time" = "Chunk process avg time"; +"Snapshot height" = "Snapshot height"; +"Snapshot chunks" = "Snapshot chunks"; +"Backfilled blocks" = "Backfilled blocks"; +"Backfill blocks total" = "Backfill blocks total"; +"Time" = "Time"; +"Node clock" = "Node clock"; +"Latest block" = "Latest block"; +"Genesis" = "Genesis"; +"Epoch" = "Epoch"; +"Matches this evonode" = "Matches this evonode"; +"Differs from this evonode" = "Differs from this evonode"; +"Not reported" = "Not reported"; +"%.3f s" = "%.3f s"; +"Node status" = "Node status"; +"Asking the evonode…" = "Asking the evonode…"; +"Request status" = "Request status";