Skip to content
Merged
32 changes: 32 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3000,6 +3008,7 @@
75F51AAC2ABD8C800057B499 /* IntegrationViewController+Uphold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntegrationViewController+Uphold.swift"; sourceTree = "<group>"; };
75F51AAE2ABD8D070057B499 /* IntegrationViewController+Coinbase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntegrationViewController+Coinbase.swift"; sourceTree = "<group>"; };
75F9D1812FE0A00100C0D001 /* ShortcutItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutItemView.swift; sourceTree = "<group>"; };
5AEEB0012F9C000000000008 /* NodesShortcutIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodesShortcutIcon.swift; sourceTree = "<group>"; };
75FEC6AE2CE0C58A00F87B48 /* VerifyIdentityScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyIdentityScreen.swift; sourceTree = "<group>"; };
75FEC6B12CE0C71700F87B48 /* DWUsernamePendingViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWUsernamePendingViewController.h; sourceTree = "<group>"; };
75FEC6B22CE0C71700F87B48 /* DWUsernamePendingViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWUsernamePendingViewController.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3661,6 +3670,9 @@
FC1426BD13E794C788646055 /* ContestedNamesService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestedNamesService.swift; sourceTree = "<group>"; };
15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoterRegistry.swift; sourceTree = "<group>"; };
23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoteCaster.swift; sourceTree = "<group>"; };
5AEEB0012F9C000000000001 /* EvonodeEpochBlocksService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeEpochBlocksService.swift; sourceTree = "<group>"; };
5AEEB0012F9C000000000005 /* EvonodeEpochBlocksMonitor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeEpochBlocksMonitor.swift; sourceTree = "<group>"; };
5AEEB0012F9C00000000000B /* EvonodeProposalActivity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeProposalActivity.swift; sourceTree = "<group>"; };
33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameVotingScreen.swift; sourceTree = "<group>"; };
6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestDetailScreen.swift; sourceTree = "<group>"; };
FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = "<group>";
};
5AEDE0012F9B000000000007 /* Masternode Withdrawal */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -7135,6 +7158,7 @@
isa = PBXGroup;
children = (
2A85F872C9D04B3BF9C2FFF3 /* Voting */,
5AEEB0012F9C000000000004 /* Masternodes */,
A11E57A70004C0DE0004B004 /* Invitations */,
33FB75FCD21FC8C62840E6FD /* SwiftDashSDKKeyMigrator.swift */,
E3CA14DC8416012B813B5E2C /* SwiftDashSDKPhraseRepairer.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Data>
private let syncModel: SyncModelImpl
private var cancellables = Set<AnyCancellable>()

private var task: Task<Void, Never>?
/// 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<Data> = 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<Data> {
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<Data>, 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)
}
}
}
Loading
Loading