diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 88694d97e..919db1e3a 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -904,6 +904,8 @@ 75F51AAF2ABD8D070057B499 /* IntegrationViewController+Coinbase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F51AAE2ABD8D070057B499 /* IntegrationViewController+Coinbase.swift */; }; 75F9D1822FE0A00100C0D001 /* ShortcutItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F9D1812FE0A00100C0D001 /* ShortcutItemView.swift */; }; 75F9D1832FE0A00100C0D001 /* ShortcutItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F9D1812FE0A00100C0D001 /* ShortcutItemView.swift */; }; + 5AEEB0012F9C000000000009 /* NodesShortcutIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000008 /* NodesShortcutIcon.swift */; }; + 5AEEB0012F9C00000000000A /* NodesShortcutIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000008 /* NodesShortcutIcon.swift */; }; 75FEC6AF2CE0C58A00F87B48 /* VerifyIdentityScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEC6AE2CE0C58A00F87B48 /* VerifyIdentityScreen.swift */; }; 75FEC6B32CE0C71700F87B48 /* DWUsernamePendingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 75FEC6B22CE0C71700F87B48 /* DWUsernamePendingViewController.m */; }; 75FFD6B82BF47BA20032879E /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FFD6B72BF47BA20032879E /* HomeViewController.swift */; }; @@ -1923,6 +1925,12 @@ 8F0D34E5B93E7D2EB9062394 /* ContestedNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1426BD13E794C788646055 /* ContestedNamesService.swift */; }; F5D223F887ABBFCEB478816F /* MasternodeVoterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */; }; 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; + 5AEEB0012F9C000000000002 /* EvonodeEpochBlocksService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */; }; + 5AEEB0012F9C000000000003 /* EvonodeEpochBlocksService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */; }; + 5AEEB0012F9C000000000006 /* EvonodeEpochBlocksMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */; }; + 5AEEB0012F9C000000000007 /* EvonodeEpochBlocksMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */; }; + 5AEEB0012F9C00000000000C /* EvonodeProposalActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */; }; + 5AEEB0012F9C00000000000D /* EvonodeProposalActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */; }; 12118430106B8A5EBB2FBC60 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; F34816414D829A45E0762757 /* ContestDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */; }; AD24D88215A4E51186112B34 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; @@ -3000,6 +3008,7 @@ 75F51AAC2ABD8C800057B499 /* IntegrationViewController+Uphold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntegrationViewController+Uphold.swift"; sourceTree = ""; }; 75F51AAE2ABD8D070057B499 /* IntegrationViewController+Coinbase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntegrationViewController+Coinbase.swift"; sourceTree = ""; }; 75F9D1812FE0A00100C0D001 /* ShortcutItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutItemView.swift; sourceTree = ""; }; + 5AEEB0012F9C000000000008 /* NodesShortcutIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodesShortcutIcon.swift; sourceTree = ""; }; 75FEC6AE2CE0C58A00F87B48 /* VerifyIdentityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyIdentityScreen.swift; sourceTree = ""; }; 75FEC6B12CE0C71700F87B48 /* DWUsernamePendingViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWUsernamePendingViewController.h; sourceTree = ""; }; 75FEC6B22CE0C71700F87B48 /* DWUsernamePendingViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWUsernamePendingViewController.m; sourceTree = ""; }; @@ -3661,6 +3670,9 @@ FC1426BD13E794C788646055 /* ContestedNamesService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestedNamesService.swift; sourceTree = ""; }; 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoterRegistry.swift; sourceTree = ""; }; 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoteCaster.swift; sourceTree = ""; }; + 5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeEpochBlocksService.swift; sourceTree = ""; }; + 5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeEpochBlocksMonitor.swift; sourceTree = ""; }; + 5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeProposalActivity.swift; sourceTree = ""; }; 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameVotingScreen.swift; sourceTree = ""; }; 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestDetailScreen.swift; sourceTree = ""; }; FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = ""; }; @@ -3752,6 +3764,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5AEEB0012F9C000000000004 /* Masternodes */ = { + isa = PBXGroup; + children = ( + 5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */, + 5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */, + 5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */, + ); + path = Masternodes; + sourceTree = ""; + }; 5AEDE0012F9B000000000007 /* Masternode Withdrawal */ = { isa = PBXGroup; children = ( @@ -4326,6 +4348,7 @@ 75B3C1B12E36100100CAFE04 /* ShortcutCustomizeBannerView.swift */, C1A0B2C3D4E5F60718293A01 /* ShortcutsBarView.swift */, 75F9D1812FE0A00100C0D001 /* ShortcutItemView.swift */, + 5AEEB0012F9C000000000008 /* NodesShortcutIcon.swift */, 75B3C1A12E36100100CAFE01 /* ShortcutSelectionView.swift */, 2A1AF6DC23C7681B00442AF5 /* DWShortcutCollectionViewCell~ipad.xib */, 2A1AF6DD23C7681B00442AF5 /* DWShortcutCollectionViewCell~iphone.xib */, @@ -7135,6 +7158,7 @@ isa = PBXGroup; children = ( 2A85F872C9D04B3BF9C2FFF3 /* Voting */, + 5AEEB0012F9C000000000004 /* Masternodes */, A11E57A70004C0DE0004B004 /* Invitations */, 33FB75FCD21FC8C62840E6FD /* SwiftDashSDKKeyMigrator.swift */, E3CA14DC8416012B813B5E2C /* SwiftDashSDKPhraseRepairer.swift */, @@ -9452,6 +9476,9 @@ F34816414D829A45E0762757 /* ContestDetailScreen.swift in Sources */, 12118430106B8A5EBB2FBC60 /* UsernameVotingScreen.swift in Sources */, 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */, + 5AEEB0012F9C000000000002 /* EvonodeEpochBlocksService.swift in Sources */, + 5AEEB0012F9C000000000006 /* EvonodeEpochBlocksMonitor.swift in Sources */, + 5AEEB0012F9C00000000000C /* EvonodeProposalActivity.swift in Sources */, F5D223F887ABBFCEB478816F /* MasternodeVoterRegistry.swift in Sources */, 8F0D34E5B93E7D2EB9062394 /* ContestedNamesService.swift in Sources */, 2AB3416223A81E8B004E37A7 /* DWReceiveModelStub.m in Sources */, @@ -9962,6 +9989,7 @@ 75B3C1B22E36100100CAFE05 /* ShortcutCustomizeBannerView.swift in Sources */, C1A0B2C3D4E5F60718293A02 /* ShortcutsBarView.swift in Sources */, 75F9D1822FE0A00100C0D001 /* ShortcutItemView.swift in Sources */, + 5AEEB0012F9C000000000009 /* NodesShortcutIcon.swift in Sources */, 75B3C1A22E36100100CAFE02 /* ShortcutSelectionView.swift in Sources */, 2A44312122CCA2A0009BAF7F /* UIColor+DWStyle.m in Sources */, 47AE8BF028C1306000490F5E /* AtmItemCell.swift in Sources */, @@ -10327,6 +10355,9 @@ D0C564276BD8B9770F0E0499 /* ContestDetailScreen.swift in Sources */, 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */, F29CC1CE29F3544F9E2F9BE7 /* MasternodeVoteCaster.swift in Sources */, + 5AEEB0012F9C000000000003 /* EvonodeEpochBlocksService.swift in Sources */, + 5AEEB0012F9C000000000007 /* EvonodeEpochBlocksMonitor.swift in Sources */, + 5AEEB0012F9C00000000000D /* EvonodeProposalActivity.swift in Sources */, EB88594208EAD0E9E1FA752F /* MasternodeVoterRegistry.swift in Sources */, BCB10EF569912077058577E4 /* ContestedNamesService.swift in Sources */, 5EAD111330A0000000A1B201 /* MayaAmountFormatter.swift in Sources */, @@ -10928,6 +10959,7 @@ 75B3C1B32E36100100CAFE06 /* ShortcutCustomizeBannerView.swift in Sources */, C1A0B2C3D4E5F60718293A03 /* ShortcutsBarView.swift in Sources */, 75F9D1832FE0A00100C0D001 /* ShortcutItemView.swift in Sources */, + 5AEEB0012F9C00000000000A /* NodesShortcutIcon.swift in Sources */, 75B3C1A32E36100100CAFE03 /* ShortcutSelectionView.swift in Sources */, 516C63E52FE9BE0A00DD6BC3 /* AboutDashView.swift in Sources */, 516C63E92FE9BE0A00DD6BC3 /* AboutDashViewModel.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift new file mode 100644 index 000000000..29f1c97ac --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksMonitor.swift @@ -0,0 +1,224 @@ +// +// EvonodeEpochBlocksMonitor.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 Combine +import Foundation +import SwiftDashSDK +import UIKit + +// MARK: - EvonodeEpochBlocksMonitor + +/// The one refresh policy for "blocks my evonodes proposed this epoch", +/// shared by every screen that shows it (home card, masternode list). +/// +/// Why a shared instance (see the singleton guardrail): one range scan of +/// the epoch's proposers serves every consumer — two screens polling +/// independently would double the network work for the same answer. The +/// fetch itself is behind the `EvonodeEpochBlocksProviding` seam and the +/// owned-evonode source is injectable, so tests can build their own. +/// +/// Behaviour: +/// - at most ONE scan in flight; a forced refresh requested while one runs +/// is queued and starts when the slot frees (never dropped); +/// - routine triggers (appear / foreground) are throttled to +/// `refreshInterval` after a success and `retryInterval` after a failure; +/// - a wallet / network switch cancels the running scan (the service stops +/// paging), drops the stale value immediately, and queues a forced scan; +/// the superseded scan's result is discarded by generation. +/// - triggers wired here: sync reaching done, app foreground, active-wallet +/// and network change. Screens only call `refresh()` on appear. +@MainActor +final class EvonodeEpochBlocksMonitor: ObservableObject { + static let shared = EvonodeEpochBlocksMonitor() + + /// Latest tallies, or `nil` when the wallet has no active evonodes or + /// nothing has been fetched yet. + @Published private(set) var blocks: EvonodeEpochBlocks? + + /// Bumped on every context-changing trigger the monitor reacts to (sync + /// reaching done, active-wallet / network switch). Screens that show + /// the masternode list observe it to reload the list itself — the + /// aggregation changes on exactly those events. + @Published private(set) var contextVersion: UInt64 = 0 + + /// Per-node proposal activity folded from every successful tally + /// (persisted per network) — what "hasn't proposed in 2 days" is judged + /// from, since Platform only reports per-epoch totals. + @Published private(set) var activity: EvonodeProposalActivity + + /// Don't re-scan more often than this on routine triggers after a + /// successful fetch… + static let refreshInterval: TimeInterval = 5 * 60 + /// …but retry sooner after a failure (Platform unreachable at launch). + static let retryInterval: TimeInterval = 30 + + private let provider: EvonodeEpochBlocksProviding + private let ownedEvonodes: @MainActor () -> Set + private let syncModel: SyncModelImpl + private var cancellables = Set() + + private var task: Task? + /// Identity of the latest requested scan; a superseded scan's outcome + /// (success or failure) must not touch published state. + private var generation: UInt64 = 0 + private var lastAttempt: Date? + private var lastFetchFailed = false + private var pendingForced = false + + init( + provider: EvonodeEpochBlocksProviding = EvonodeEpochBlocksService(), + ownedEvonodes: @escaping @MainActor () -> Set = EvonodeEpochBlocksMonitor.activeEvonodeProTxHashes, + syncModel: SyncModelImpl = SyncModelImpl() + ) { + self.provider = provider + self.ownedEvonodes = ownedEvonodes + self.syncModel = syncModel + self.activity = Self.activityStore().load() + observeTriggers() + } + + /// Activity is kept per network: the same wallet runs different + /// masternodes on testnet and mainnet. + private static func activityStore() -> EvonodeProposalActivityStore { + EvonodeProposalActivityStore(network: WalletEnvironment.isTestnet ? "testnet" : "mainnet") + } + + /// The wallet's evonodes still on the network (not retired), by stored + /// proTxHash — the Rust aggregation; local, no network. + 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)) + } + + // MARK: Triggers + + private func observeTriggers() { + syncModel.$state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard state == .syncDone else { return } + Task { @MainActor [weak self] in + guard let self else { return } + self.contextVersion &+= 1 + self.refresh(force: true) + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.refresh() } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) + .merge(with: NotificationCenter.default.publisher(for: NSNotification.Name.DWCurrentNetworkDidChange)) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.reset() } + } + .store(in: &cancellables) + } + + // MARK: Refresh + + /// Fetch (or re-fetch) the tallies. Routine calls honour the throttle; + /// `force` bypasses it. While a scan is running, a forced request is + /// queued behind it; a routine one is dropped (the running scan answers). + func refresh(force: Bool = false) { + if task != nil { + if force { pendingForced = true } + return + } + let minInterval = lastFetchFailed ? Self.retryInterval : Self.refreshInterval + if !force, let last = lastAttempt, Date().timeIntervalSince(last) < minInterval { + return + } + let owned = ownedEvonodes() + guard !owned.isEmpty else { + if blocks != nil { + DWLogger.log("EvonodeEpochBlocksMonitor: no active evonodes in the wallet aggregation — clearing") + } + blocks = nil + return + } + start(owned: owned, force: force) + } + + /// Wallet / network switch: the current value and any running scan are + /// for the old context. Drop the value now (hides the card), let the + /// running scan stop paging, and queue a forced scan behind it. + func reset() { + generation &+= 1 + contextVersion &+= 1 + blocks = nil + activity = Self.activityStore().load() + lastAttempt = nil + lastFetchFailed = false + if let task { + task.cancel() + pendingForced = true + } else { + refresh(force: true) + } + } + + private func start(owned: Set, force: Bool) { + lastAttempt = Date() + generation &+= 1 + let generation = self.generation + let provider = self.provider + DWLogger.log("EvonodeEpochBlocksMonitor: fetching for \(owned.count) evonode(s), force=\(force)") + task = Task { [weak self] in + do { + let blocks = try await provider.fetch(ownedProTxHashes: owned) + guard let self, self.generation == generation, !Task.isCancelled else { return } + self.blocks = blocks + self.lastFetchFailed = false + var activity = self.activity + activity.record(blocks) + self.activity = activity + Self.activityStore().save(activity) + DWLogger.log("EvonodeEpochBlocksMonitor: \(blocks.totalBlocks) block(s) this epoch (epoch \(blocks.epochIndex.map(String.init) ?? "?"))") + } catch { + // A superseded / cancelled scan says nothing about the current + // context — only a live one marks the retry state. + guard let self, self.generation == generation, !Task.isCancelled else { return } + self.lastFetchFailed = true + DWLogger.log("EvonodeEpochBlocksMonitor: fetch failed: \(error)") + } + self?.finish() + } + } + + /// The running scan ended (any outcome). Free the slot, then run the + /// forced refresh that was queued behind it, if any. + private func finish() { + task = nil + if pendingForced { + pendingForced = false + refresh(force: true) + } + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksService.swift new file mode 100644 index 000000000..a0d522037 --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeEpochBlocksService.swift @@ -0,0 +1,283 @@ +// +// EvonodeEpochBlocksService.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 OSLog +import SwiftDashSDK + +// MARK: - EvonodeEpochBlocks + +/// How many Platform blocks this wallet's evonodes have proposed in the +/// current epoch, per node and in total. +struct EvonodeEpochBlocks: Equatable { + /// The epoch the tallies are for (the current epoch at fetch time). `nil` + /// only for the empty "no evonodes" result. + let epochIndex: UInt32? + /// When the current epoch started, when known. + let epochStart: Date? + /// Blocks proposed this epoch keyed by the evonode's proTxHash (stored + /// WIRE order, as `PlatformMasternode.proTxHash`). Every owned evonode + /// is present; nodes that haven't proposed yet are `0`. + let blocksByProTxHash: [Data: UInt64] + let fetchedAt: Date + + var totalBlocks: UInt64 { + blocksByProTxHash.values.reduce(0, +) + } + + var evonodeCount: Int { blocksByProTxHash.count } +} + +// MARK: - EvonodeEpochBlocksProviding + +/// Seam for the home screen / masternode list: fetch the current-epoch +/// proposal tallies for a set of owned evonodes. +protocol EvonodeEpochBlocksProviding: AnyObject { + func fetch(ownedProTxHashes: Set) async throws -> EvonodeEpochBlocks +} + +// MARK: - EvonodeEpochBlocksService + +/// Fetches current-epoch block-proposal tallies for the wallet's evonodes +/// WITHOUT revealing which evonodes the wallet owns. +/// +/// Privacy rule (owner-mandated): the wallet never sends its own proTxHashes +/// to DAPI for this. Instead of `getEvonodesProposedEpochBlocksByIds` (whose +/// request would list exactly our nodes), this pages through the whole +/// epoch's proposer tallies with `getEvonodesProposedEpochBlocksByRange` — +/// a request that carries no node ids at all, identical for every wallet — +/// and joins the result against the owned set on the device. A DAPI node +/// therefore learns only that some client read the epoch's tallies, which is +/// the same thing a block explorer does. +/// +/// Cost: one proved query per page of proposers (Drive serves up to 100 +/// entries per page), so a handful of round-trips per refresh; the home +/// screen refreshes at most every few minutes. +final class EvonodeEpochBlocksService: EvonodeEpochBlocksProviding { + enum ServiceError: LocalizedError { + case sdkUnavailable + case malformedEntry + /// The proposer list was still paging at `maxPages` — the scan is + /// incomplete, so no tally is returned (callers keep their last value). + case pageLimitReached + /// The current epoch could not be resolved, so no tally can be asked + /// for: the proof verifier requires an explicit epoch index. + case currentEpochUnavailable(String) + + var errorDescription: String? { + switch self { + case .sdkUnavailable: return "Platform SDK not ready" + case .malformedEntry: return "Unexpected proposer tally format" + case .pageLimitReached: return "Proposer list too long to scan" + case let .currentEpochUnavailable(reason): return "Current epoch unavailable: \(reason)" + } + } + } + + /// Drive's page size for the range query (the SDK bridge does not pass a + /// limit; Platform serves up to 100 proposers per response). A full page + /// means there may be more — keep paging; a short page ends the scan. + private static let pageSize = 100 + /// Runaway guard on paging (5000 evonodes ≫ any Dash network). + private static let maxPages = 50 + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "swift-sdk-migration.evonode-epoch-blocks") + + init() {} + + func fetch(ownedProTxHashes: Set) async throws -> EvonodeEpochBlocks { + // Nothing to tally ⇒ nothing to ask the network: a wallet without + // evonodes must issue no query at all. + guard !ownedProTxHashes.isEmpty else { + return EvonodeEpochBlocks(epochIndex: nil, epochStart: nil, blocksByProTxHash: [:], fetchedAt: Date()) + } + guard let sdk = await SwiftDashSDKHost.shared.sdk else { + throw ServiceError.sdkUnavailable + } + // Match in either byte order: `PlatformMasternode.proTxHash` is stored + // wire order and the bridge hex-encodes the proposer hash's raw bytes, + // which should be the same orientation — but a hash never equals its + // own reversal, so accepting both can't mis-attribute a tally and + // guards against an orientation drift in either source. + var lookup: [Data: Data] = [:] + for hash in ownedProTxHashes { + lookup[hash] = hash + lookup[Data(hash.reversed())] = hash + } + let owned = ownedProTxHashes + + // The SDK query bridges block the calling thread (isolated Tokio + // runtime per call) — keep them off the main actor. A detached task + // doesn't inherit the caller's cancellation, so it is forwarded + // through `stop`: a cancelled caller stops the scan at the next page + // boundary (an in-flight FFI call can't be aborted) and gets + // `CancellationError` instead of a tally. + let stop = CancelFlag() + return try await withTaskCancellationHandler { + try await Task.detached(priority: .utility) { () -> EvonodeEpochBlocks in + // The current epoch comes first, and is REQUIRED: proved proposer + // queries must name an explicit epoch (the proof verifier rejects + // "current"), and the bridge maps epoch 0 to "unspecified". + let (epochIndex, epochStart) = try await Self.currentEpoch(sdk) + guard epochIndex > 0 else { + throw ServiceError.currentEpochUnavailable("epoch 0 cannot be queried through the range bridge") + } + + var tallies: [Data: UInt64] = Dictionary(uniqueKeysWithValues: owned.map { ($0, 0) }) + var startAfter: String? + var pages = 0 + var scanned = 0 + while true { + if stop.isSet { throw CancellationError() } + let page = try await sdk.getEvonodesProposedEpochBlocksByRange( + epoch: epochIndex, + limit: UInt32(Self.pageSize), + startAfter: startAfter, + orderAscending: true) + pages += 1 + scanned += page.count + for entry in page { + let (hash, count) = try Self.parseTally(entry) + if let mine = lookup[hash] { + tallies[mine] = count + } + } + // A short page is the end of the list. A full page means there + // may be more — and a scan that is still paging at the guard + // is incomplete, so it must not be reported as a tally. + guard page.count >= Self.pageSize, + let last = page.last?["pro_tx_hash"] as? String else { + break + } + guard pages < Self.maxPages else { + throw ServiceError.pageLimitReached + } + startAfter = last + } + + Self.logger.info( + "🏛️ EVONODE-BLOCKS :: scanned \(scanned) proposers over \(pages) page(s); \(owned.count) owned evonode(s), epoch \(epochIndex, privacy: .public)") + if stop.isSet { throw CancellationError() } + return EvonodeEpochBlocks( + epochIndex: epochIndex, + epochStart: epochStart, + blocksByProTxHash: tallies, + fetchedAt: Date()) + }.value + } onCancel: { + stop.set() + } + } + + /// The current (newest started) epoch — `SDK.getCurrentEpoch()` (the + /// proved two-query probe behind `dash_sdk_system_get_current_epoch`). + private static func currentEpoch(_ sdk: SDK) async throws -> (index: UInt32, start: Date?) { + let epoch: [String: Any] + do { + epoch = try await sdk.getCurrentEpoch() + } catch { + throw ServiceError.currentEpochUnavailable(String(describing: error)) + } + guard let index = epoch["index"] as? NSNumber else { + throw ServiceError.currentEpochUnavailable("no index in the epoch record") + } + var start: Date? + if let startMs = epoch["first_block_time"] as? NSNumber, startMs.doubleValue > 0 { + start = Date(timeIntervalSince1970: startMs.doubleValue / 1000) + } + return (index.uint32Value, start) + } +} + +// MARK: - Tally parsing + +private extension EvonodeEpochBlocksService { + /// One `{"pro_tx_hash": hex, "count": n}` bridge entry → (hash, count). + static func parseTally(_ entry: [String: Any]) throws -> (Data, UInt64) { + guard let hex = entry["pro_tx_hash"] as? String, + let hash = Data(hexString: hex) else { + throw ServiceError.malformedEntry + } + // Parse through the decimal text so a negative or fractional count is + // rejected (`uint64Value` would wrap it into a huge tally). + let text: String + if let number = entry["count"] as? NSNumber { + text = number.stringValue + } else if let string = entry["count"] as? String { + text = string + } else { + throw ServiceError.malformedEntry + } + guard let parsed = UInt64(text) else { + throw ServiceError.malformedEntry + } + return (hash, parsed) + } +} + +// MARK: - CancelFlag + +/// One-way "stop" signal handed to the detached scan (which can't see the +/// caller's task cancellation). +private final class CancelFlag: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + var isSet: Bool { + lock.lock(); defer { lock.unlock() } + return cancelled + } + + func set() { + lock.lock(); defer { lock.unlock() } + cancelled = true + } +} + +// MARK: - Data(hexString:) + +private extension Data { + /// Strict hex decoding (even length, hex digits only); `nil` otherwise. + init?(hexString: String) { + let chars = Array(hexString.utf8) + guard chars.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var index = 0 + while index < chars.count { + guard let hi = Self.nibble(chars[index]), let lo = Self.nibble(chars[index + 1]) else { + return nil + } + bytes.append(hi << 4 | lo) + index += 2 + } + self.init(bytes) + } + + private static func nibble(_ c: UInt8) -> UInt8? { + switch c { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return c - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return c - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return c - UInt8(ascii: "A") + 10 + default: return nil + } + } +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeProposalActivity.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeProposalActivity.swift new file mode 100644 index 000000000..dd6062b6b --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Masternodes/EvonodeProposalActivity.swift @@ -0,0 +1,144 @@ +// +// EvonodeProposalActivity.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 + +// MARK: - EvonodeProposalActivity + +/// Longitudinal view of each owned evonode's block proposals, derived from +/// the per-epoch tallies the monitor fetches over time. Platform only +/// reports totals per epoch, not when each block was proposed, so "has this +/// node proposed recently?" is answered locally: every successful tally is +/// compared with the previous one and the moment a node's count is seen to +/// grow is remembered. Persisted across launches (UserDefaults), keyed by +/// proTxHash. +struct EvonodeProposalActivity: Codable, Equatable { + struct Node: Codable, Equatable { + /// Epoch of `count` (tallies reset at an epoch boundary). + var epochIndex: UInt32 + /// Last observed count for `epochIndex`. + var count: UInt64 + /// When this node was first observed at all — the baseline before + /// any increase has been seen. + var firstSeenAt: Date + /// When the count was last seen to increase; `nil` until it has. + var lastIncreaseAt: Date? + } + + /// Keyed by the proTxHash bytes (stored wire order). + var nodes: [Data: Node] = [:] + + /// A node counts as "not proposing" once this long has passed without an + /// observed increase (from its first observation, or its last increase). + static let staleInterval: TimeInterval = 2 * 24 * 60 * 60 + + /// Fold a fresh tally in. Nodes absent from `blocks` are dropped (no + /// longer owned / retired); an epoch change makes any positive count an + /// increase (the previous epoch's total doesn't carry over). + mutating func record(_ blocks: EvonodeEpochBlocks, now: Date = Date()) { + guard let epoch = blocks.epochIndex else { return } + var updated: [Data: Node] = [:] + for (hash, count) in blocks.blocksByProTxHash { + if var node = nodes[hash] { + let previous = node.epochIndex == epoch ? node.count : 0 + if count > previous { + node.lastIncreaseAt = now + } + node.epochIndex = epoch + node.count = count + updated[hash] = node + } else { + updated[hash] = Node( + epochIndex: epoch, + count: count, + firstSeenAt: now, + lastIncreaseAt: count > 0 ? now : nil) + } + } + nodes = updated + } + + /// Nodes with no observed proposal in the last `staleInterval` — measured + /// from the last increase, or from first observation if none was ever + /// seen. A node observed for less than the interval is never stale yet. + func staleNodes(now: Date = Date()) -> Set { + Set(nodes.compactMap { hash, node in + let reference = node.lastIncreaseAt ?? node.firstSeenAt + return now.timeIntervalSince(reference) >= Self.staleInterval ? hash : nil + }) + } +} + +// MARK: - EvonodeProposalActivityStore + +/// UserDefaults persistence for `EvonodeProposalActivity`, per network (the +/// same wallet sees different masternodes on testnet and mainnet). +struct EvonodeProposalActivityStore { + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, network: String) { + self.defaults = defaults + self.key = "org.dash.evonodeProposalActivity.\(network)" + } + + func load() -> EvonodeProposalActivity { + guard let data = defaults.data(forKey: key), + let activity = try? JSONDecoder().decode(EvonodeProposalActivity.self, from: data) else { + return EvonodeProposalActivity() + } + return activity + } + + func save(_ activity: EvonodeProposalActivity) { + guard let data = try? JSONEncoder().encode(activity) else { return } + defaults.set(data, forKey: key) + } +} + +// MARK: - EvonodeHealth + +/// What the Nodes icon colour says about the owned evonodes. +enum EvonodeHealth: Equatable { + /// Every node has proposed recently. + case healthy + /// At least one node has not proposed a block in the last 2 days. + case warning + /// We are on epoch day 4 or later and at least one node still has no + /// block in this epoch. + case critical + + /// Derive the state for `now` from the current tallies and the tracked + /// activity. `epochDay` is the 0-based day of the epoch. + static func evaluate( + blocks: EvonodeEpochBlocks?, + activity: EvonodeProposalActivity, + epochDay: Int?, + now: Date = Date() + ) -> EvonodeHealth { + guard let blocks else { return .healthy } + if let epochDay, epochDay >= 4, blocks.blocksByProTxHash.values.contains(0) { + return .critical + } + if !activity.staleNodes(now: now).isEmpty { + return .warning + } + return .healthy + } +} diff --git a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift index 99cdabae5..534c83d0a 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift @@ -76,6 +76,8 @@ extension HomeViewController: DWLocalCurrencyViewControllerDelegate { showSwitchWallet() case .dashDEX: dashDEXAction() + case .nodes: + homeViewShowMasternodes() } } diff --git a/DashWallet/Sources/UI/Home/HomeViewController.swift b/DashWallet/Sources/UI/Home/HomeViewController.swift index 27da22df3..75d149cb5 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController.swift @@ -117,6 +117,9 @@ class HomeViewController: DWBasePayViewController, NavigationBarDisplayable { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // Evonode epoch-blocks card: cheap no-op inside its refresh throttle. + viewModel.refreshEvonodeEpochBlocks() + // Refresh the SDK's local DPNS-names cache from the blockchain // so the avatar/profile sheet/Edit Profile see legitimately- // owned names that weren't written by registerDpnsName in this @@ -634,6 +637,12 @@ extension HomeViewController: HomeViewDelegate { func homeViewShowSend(network: ChainNetwork) { delegate?.showSendLanding(network: network) } + + func homeViewShowMasternodes() { + guard let navigation = navigationController, + navigation.topViewController === self else { return } + navigation.pushViewController(MasternodesScreen.hostingController(popFrom: navigation), animated: true) + } #if DASHPAY func homeView(_ homeView: HomeView, didUpdateProfileWithUnreadNotifications unreadNotifications: UInt) { diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 25f928789..e8e99a7bc 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -31,6 +31,8 @@ protocol HomeViewDelegate: AnyObject { /// The mirror for the balance rows' send (out) arrows: the send sheet /// (Send ↔ Internal) pinned to `network` as the source. func homeViewShowSend(network: ChainNetwork) + /// Opens the wallet's masternode list (the Nodes shortcut). + func homeViewShowMasternodes() /// Scroll-derived chrome: false at the top of the feed (bar hidden, /// balance header owns the space), true once the user scrolls down. func homeViewDidChangeTopBarVisibility(shouldShow: Bool) diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index ad7f5dbe0..e17d32a88 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -20,6 +20,7 @@ import Combine import CoreData import SwiftData import SwiftDashSDK +import UIKit private let kBaseBalanceHeaderHeight: CGFloat = 100 private let kTimeskewTolerance: TimeInterval = 3600 // 1 hour @@ -200,6 +201,14 @@ class HomeViewModel: ObservableObject { #endif private lazy var syncModel = SyncModelImpl() + + // MARK: Evonode epoch blocks + + /// Shared refresh policy for "blocks my evonodes proposed this epoch" + /// (the Nodes shortcut icon reads it directly). Home only nudges it on + /// appear; the monitor owns throttling, sync/foreground/wallet triggers + /// and the privacy-preserving range scan. + private let evonodeEpochBlocksMonitor: EvonodeEpochBlocksMonitor private var reclassifyTransactionsActivatedAt: Date { get { DWGlobalOptions.sharedInstance().dateReclassifyYourTransactionsFlowActivated ?? Date() } @@ -216,8 +225,12 @@ class HomeViewModel: ObservableObject { } } - init(transactionSource: TransactionSource) { + init( + transactionSource: TransactionSource, + evonodeEpochBlocksMonitor: EvonodeEpochBlocksMonitor = .shared + ) { self.transactionSource = transactionSource + self.evonodeEpochBlocksMonitor = evonodeEpochBlocksMonitor syncModel.networkStatusDidChange = { status in self.recalculateHeight() } @@ -241,11 +254,21 @@ class HomeViewModel: ObservableObject { #endif } + // MARK: - Evonode epoch blocks + + /// Routine refresh trigger (screen appear): throttled by the monitor. + @MainActor + func refreshEvonodeEpochBlocks() { + guard !isPreviewMode else { return } + evonodeEpochBlocksMonitor.refresh() + } + #if DEBUG /// Lightweight init used only by SwiftUI previews. /// Skips wallet/sync/coinjoin wiring that depends on the Dash core runtime. private init(previewShortcuts: [ShortcutAction]) { self.transactionSource = HomeViewModelPreviewTransactionSource() + self.evonodeEpochBlocksMonitor = .shared self.isPreviewMode = true self.shortcutItems = previewShortcuts } @@ -1494,6 +1517,7 @@ extension HomeViewModel { DispatchQueue.main.async { [weak self] in guard let self else { return } let canSwitchWallet = MainActor.assumeIsolated { WalletsViewModel.switchableWalletCount > 1 } + let hasEvonodes = MainActor.assumeIsolated { !EvonodeEpochBlocksMonitor.activeEvonodeProTxHashes().isEmpty } let items = customShortcuts.compactMap { number -> ShortcutAction? in guard var type = ShortcutActionType(rawValue: number.intValue) else { return nil } // A faucet shortcut saved on testnet degrades to Spend @@ -1517,6 +1541,13 @@ extension HomeViewModel { if type == .dashDEX && !dashDEXAvailable { type = .spend } + // A saved Nodes shortcut degrades to Spend (faucet on + // testnet) while the active wallet runs no evonodes — e.g. + // after a wallet switch; the saved config is untouched, so + // it returns with an evonode wallet. + if type == .nodes && !hasEvonodes { + type = isTestnet ? .getTestDash : .spend + } return ShortcutAction(type: type) } .filter { action in diff --git a/DashWallet/Sources/UI/Home/Views/Shortcuts/Models/ShortcutAction.swift b/DashWallet/Sources/UI/Home/Views/Shortcuts/Models/ShortcutAction.swift index 7df45b0d8..07cc3b850 100644 --- a/DashWallet/Sources/UI/Home/Views/Shortcuts/Models/ShortcutAction.swift +++ b/DashWallet/Sources/UI/Home/Views/Shortcuts/Models/ShortcutAction.swift @@ -47,6 +47,10 @@ enum ShortcutActionType: Int { case getTestDash case switchWallet case dashDEX + /// Evonode owners: epoch day + blocks proposed this epoch, opens the + /// masternode list. Opt-in only — offered in the customization picker + /// while the wallet has active evonodes, never placed by default. + case nodes } extension ShortcutActionType { @@ -77,6 +81,10 @@ extension ShortcutActionType { if WalletsViewModel.switchableWalletCount > 1 { actions.append(.switchWallet) } + // Nodes only means something to a wallet that runs evonodes. + if !EvonodeEpochBlocksMonitor.activeEvonodeProTxHashes().isEmpty { + actions.append(.nodes) + } return actions } @@ -131,6 +139,10 @@ extension ShortcutActionType { return "shortcut_switchNetwork" case .dashDEX: return "shortcut-dash-dex" + case .nodes: + // Static stand-in (picker rows, UIKit paths); the shortcut bar + // renders the live `NodesShortcutIcon` instead. + return "masternode-keys" } } @@ -194,6 +206,8 @@ extension ShortcutActionType { return NSLocalizedString("Switch Wallet", comment: "Translate it as short as possible! (24 symbols max)") case .dashDEX: return NSLocalizedString("Dash DEX", comment: "Translate it as short as possible! (24 symbols max)") + case .nodes: + return NSLocalizedString("Nodes", comment: "Shortcut") } } diff --git a/DashWallet/Sources/UI/Home/Views/Shortcuts/NodesShortcutIcon.swift b/DashWallet/Sources/UI/Home/Views/Shortcuts/NodesShortcutIcon.swift new file mode 100644 index 000000000..8d4eec08e --- /dev/null +++ b/DashWallet/Sources/UI/Home/Views/Shortcuts/NodesShortcutIcon.swift @@ -0,0 +1,156 @@ +// +// NodesShortcutIcon.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 SwiftUI + +// MARK: - NodesShortcutIcon + +/// The "Nodes" shortcut's 46 pt disc: the rim is a **progress ring** for how +/// far the current epoch has run, the **epoch day** (0–9) sits large in the +/// centre, and a white badge on the bottom edge carries the blocks this +/// wallet's evonodes have **proposed this epoch**. The disc colour is the +/// fleet's health: blue when every node is proposing, yellow when one +/// hasn't proposed a block in 2 days, red when we're on epoch day 4+ and a +/// node still has none this epoch. +/// +/// Reads the shared `EvonodeEpochBlocksMonitor`; everything time-based is +/// recomputed every minute locally. Shows "–" until the first tally. +struct NodesShortcutIcon: View { + @ObservedObject private var monitor: EvonodeEpochBlocksMonitor + + /// Mainnet epochs are 9.125 days (`EPOCH_CHANGE_TIME_MS`), so the day + /// index runs 0…9 and the ring fills over that span. + static let epochLength: TimeInterval = 9.125 * 24 * 60 * 60 + static let maxEpochDay = 9 + /// Block counts beyond this render as "99+" so they stay legible. + static let maxDisplayedBlocks: UInt64 = 99 + + init(monitor: EvonodeEpochBlocksMonitor = .shared) { + self.monitor = monitor + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + let now = context.date + let start = monitor.blocks?.epochStart + let day = Self.epochDay(start: start, now: now) + let blocks = monitor.blocks?.totalBlocks + let health = EvonodeHealth.evaluate( + blocks: monitor.blocks, + activity: monitor.activity, + epochDay: day, + now: now) + disc( + progress: Self.epochProgress(start: start, now: now), + dayText: day.map(String.init) ?? "–", + blocksText: Self.blocksText(blocks), + tint: Self.tint(for: health)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Self.accessibilityLabel(day: day, blocks: blocks, health: health)) + } + } + + private func disc(progress: Double, dayText: String, blocksText: String, tint: Color) -> some View { + ZStack { + Circle() + .fill(tint) + + // Epoch progress ring: faint track, solid arc from 12 o'clock. + Circle() + .stroke(Color.white.opacity(0.25), lineWidth: 2.5) + .padding(3) + Circle() + .trim(from: 0, to: max(0.02, progress)) + .stroke(Color.white, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(3) + + // Epoch day — the big number. + Text(dayText) + .font(.system(size: 19, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .offset(y: -3) + + // Blocks proposed this epoch — badge on the bottom edge. + Text(blocksText) + .font(.system(size: 8.5, weight: .heavy, design: .rounded)) + .foregroundColor(tint) + .lineLimit(1) + .minimumScaleFactor(0.7) + .padding(.horizontal, 5) + .frame(height: 12) + .background(Capsule().fill(Color.white)) + .offset(y: 16) + } + .frame(width: 46, height: 46) + } + + static func tint(for health: EvonodeHealth) -> Color { + switch health { + case .healthy: return Color.dash.blue + case .warning: return .orange + case .critical: return .red + } + } + + /// Whole days since the epoch started, clamped to 0…`maxEpochDay`; `nil` + /// when the epoch start isn't known yet. + static func epochDay(start: Date?, now: Date) -> Int? { + guard let start else { return nil } + let days = Int(floor(now.timeIntervalSince(start) / 86_400)) + return min(max(days, 0), maxEpochDay) + } + + /// Fraction of the epoch elapsed, 0…1 (0 while the start is unknown). + static func epochProgress(start: Date?, now: Date) -> Double { + guard let start else { return 0 } + return min(max(now.timeIntervalSince(start) / epochLength, 0), 1) + } + + static func blocksText(_ blocks: UInt64?) -> String { + guard let blocks else { return "–" } + return blocks > maxDisplayedBlocks ? "\(maxDisplayedBlocks)+" : "\(blocks)" + } + + static func accessibilityLabel(day: Int?, blocks: UInt64?, health: EvonodeHealth) -> String { + guard let day, let blocks else { + return NSLocalizedString("Nodes", comment: "Shortcut") + } + var label = String( + format: NSLocalizedString("Nodes, epoch day %d, %@ blocks proposed this epoch", comment: "Nodes shortcut accessibility"), + day, + blocksText(blocks)) + switch health { + case .healthy: + break + case .warning: + label += ", " + NSLocalizedString("an evonode has not proposed a block in 2 days", comment: "Nodes shortcut accessibility") + case .critical: + label += ", " + NSLocalizedString("an evonode has no blocks this epoch", comment: "Nodes shortcut accessibility") + } + return label + } +} + +#if DEBUG +#Preview { + NodesShortcutIcon() + .padding() +} +#endif diff --git a/DashWallet/Sources/UI/Home/Views/Shortcuts/ShortcutItemView.swift b/DashWallet/Sources/UI/Home/Views/Shortcuts/ShortcutItemView.swift index 8accdb877..7b3b01e62 100644 --- a/DashWallet/Sources/UI/Home/Views/Shortcuts/ShortcutItemView.swift +++ b/DashWallet/Sources/UI/Home/Views/Shortcuts/ShortcutItemView.swift @@ -22,6 +22,9 @@ import UIKit struct ShortcutItemView: View { let title: String let iconName: String + /// The action rendered, when known — the Nodes shortcut draws a live + /// icon (epoch day / blocks proposed) instead of a static asset. + var actionType: ShortcutActionType? = nil var alpha: CGFloat = 1.0 init(title: String, iconName: String) { @@ -32,12 +35,17 @@ struct ShortcutItemView: View { init(model: ShortcutAction) { self.title = model.type.shortTitle self.iconName = model.type.iconName + self.actionType = model.type } var body: some View { VStack(spacing: 5) { - Icon(name: .custom(iconName)) - .frame(width: 46, height: 46, alignment: .center) + if actionType == .nodes { + NodesShortcutIcon() + } else { + Icon(name: .custom(iconName)) + .frame(width: 46, height: 46, alignment: .center) + } Text(title) .font(.caption2.weight(.semibold)) diff --git a/DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift b/DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift index 073f7ab08..dc0b3df7f 100644 --- a/DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift +++ b/DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift @@ -89,33 +89,11 @@ struct GovernanceMenuScreen: View { } } - /// Same hosting wrapper Tools used when this screen lived there: the + /// Shared hosting wrapper (`MasternodesScreen.hostingController`): the /// SwiftUI list needs its own `NavigationStack` for row drill-downs, so /// the back button is wired explicitly to pop the UIKit stack. private func showMasternodes() { - let navController = vc - let popRoot: () -> Void = { [weak navController] in - _ = navController?.popViewController(animated: true) - } - - let hosting = UIHostingController( - rootView: AnyView( - NavigationStack { - MasternodesScreen() - .navigationTitle(NSLocalizedString("Masternodes", comment: "Masternodes")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: popRoot) { - Image(systemName: "chevron.left") - } - } - } - } - ) - ) - hosting.hidesBottomBarWhenPushed = true - vc.pushViewController(hosting, animated: true) + vc.pushViewController(MasternodesScreen.hostingController(popFrom: vc), animated: true) } #if DASHPAY diff --git a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift index 52876320e..01c2f3bae 100644 --- a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift @@ -17,6 +17,7 @@ // limitations under the License. // +import Combine import DashUIKit import SwiftDashSDK import SwiftUI @@ -32,6 +33,31 @@ import UIKit final class MasternodesViewModel: ObservableObject { @Published private(set) var masternodes: [PlatformMasternode] = [] @Published private(set) var loaded = false + /// Current-epoch proposal tallies for the wallet's evonodes, mirrored + /// from the shared `EvonodeEpochBlocksMonitor` (privacy-preserving range + /// scan, one refresh policy for every screen). `nil` until fetched / + /// when there are no active evonodes. + @Published private(set) var epochBlocks: EvonodeEpochBlocks? + + private let epochBlocksMonitor: EvonodeEpochBlocksMonitor + private var cancellables = Set() + + init(epochBlocksMonitor: EvonodeEpochBlocksMonitor = .shared) { + self.epochBlocksMonitor = epochBlocksMonitor + epochBlocksMonitor.$blocks + .receive(on: DispatchQueue.main) + .sink { [weak self] blocks in self?.epochBlocks = blocks } + .store(in: &cancellables) + // The list and its ownership indexes change on the same events the + // monitor reacts to (sync done, wallet / network switch) — reload + // them while the screen stays visible. + epochBlocksMonitor.$contextVersion + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.load() } + .store(in: &cancellables) + } /// Registrations still on the network — active, PoSe-banned, or (before /// the list arrives) indeterminate. These stay expanded at the top: an @@ -72,6 +98,14 @@ final class MasternodesViewModel: ObservableObject { votingIndexByAddress = MasternodeKeyUsage.indexByAddress( family: .voting, targets: Set(masternodes.compactMap(\.votingAddress))) + // Routine trigger (screen appear) — the monitor throttles, and reacts + // to sync-done / foreground / wallet / network changes on its own. + epochBlocksMonitor.refresh() + } + + /// Blocks proposed this epoch by `masternode`, once fetched (evonodes only). + func epochBlocks(for masternode: PlatformMasternode) -> UInt64? { + epochBlocks?.blocksByProTxHash[masternode.proTxHash] } /// Ownership subtitle for the owner key ("ProviderOwnerKeys #4" / @@ -176,6 +210,33 @@ extension PlatformMasternode { struct MasternodesScreen: View { @StateObject private var viewModel = MasternodesViewModel() + /// The UIKit wrapper every entry point (Governance menu, home card) uses: + /// the SwiftUI list needs its own `NavigationStack` for row drill-downs, + /// so the back button is wired explicitly to pop the UIKit stack. + static func hostingController(popFrom navigation: UINavigationController) -> UIViewController { + let popRoot: () -> Void = { [weak navigation] in + _ = navigation?.popViewController(animated: true) + } + let hosting = UIHostingController( + rootView: AnyView( + NavigationStack { + MasternodesScreen() + .navigationTitle(NSLocalizedString("Masternodes", comment: "Masternodes")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: popRoot) { + Image(systemName: "chevron.left") + } + } + } + } + ) + ) + hosting.hidesBottomBarWhenPushed = true + return hosting + } + /// Retired registrations are history — collapsed until asked for. @State private var isRetiredExpanded = false @@ -244,7 +305,9 @@ struct MasternodesScreen: View { votingOwnership: viewModel.votingOwnership(for: masternode), ownerKeyIndexHint: viewModel.ownerKeyIndex(for: masternode)) } label: { - MasternodeListRow(masternode: masternode) + MasternodeListRow( + masternode: masternode, + epochBlocks: masternode.isEvonode ? viewModel.epochBlocks(for: masternode) : nil) } } } @@ -253,6 +316,14 @@ struct MasternodesScreen: View { private struct MasternodeListRow: View { let masternode: PlatformMasternode + /// Blocks proposed this epoch (evonodes, once fetched). + var epochBlocks: UInt64? = nil + + private static let countFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() var body: some View { HStack { @@ -266,6 +337,16 @@ private struct MasternodeListRow: View { .font(.caption) .foregroundColor(Color.dash.secondaryText) } + + if let epochBlocks { + Text(epochBlocks == 1 + ? NSLocalizedString("1 block proposed this epoch", comment: "Evonode epoch blocks card") + : String( + format: NSLocalizedString("%@ blocks proposed this epoch", comment: "Evonode epoch blocks card"), + Self.countFormatter.string(from: NSNumber(value: epochBlocks)) ?? "\(epochBlocks)")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } } Spacer() diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 7a3ffc0d1..15ce96108 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5621,3 +5621,9 @@ "Couldn't check which keys of this evonode are in the wallet." = "Couldn't check which keys of this evonode are in the wallet."; "Result not confirmed" = "Result not confirmed"; "The withdrawal was sent to Dash Platform, but its result couldn't be confirmed. It may have gone through. Don't submit it again — check the claimable balance and the payout address first." = "The withdrawal was sent to Dash Platform, but its result couldn't be confirmed. It may have gone through. Don't submit it again — check the claimable balance and the payout address first."; +"1 block proposed this epoch" = "1 block proposed this epoch"; +"%@ blocks proposed this epoch" = "%@ blocks proposed this epoch"; +"Nodes" = "Nodes"; +"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";