diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift index d2d0e1975..ee5869325 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift @@ -34,12 +34,14 @@ // triggered from Home appear/foreground. (The upstream // `GetDataContractsRequest.version = None` bug that once blocked // `syncDpnsNames`/`fetchContestVoteState` was fixed in the v11 -// pin, 2026-05-27.) A user-facing contest-status VIEW remains -// future work. -// - One UserDefaults bookmark per Platform network — v1 pins to -// one in-flight contested submission per identity and network. -// NOT read by any carveout viewmodel (`JoinDashPayViewModel`, -// `HomeViewModel`). +// pin, 2026-05-27.) +// - MANY submissions can be in flight at once (each label is its own +// network vote poll; the marketplace lets the user request several). +// The store is one UserDefaults dictionary per (network, wallet): +// canonical label → {submittedAt, votingEnd}. The single-value +// `pendingLabel` / `pendingVotingEndTime` remain as OLDEST-entry +// conveniences for the setup-flow surfaces, which only ever deal +// with the first username. // import Foundation @@ -57,13 +59,20 @@ public final class DWContestedNameStatusService: NSObject { subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.contested-name") - /// UserDefaults key prefixes for the pending-submission bookmark. - /// A suffix is added for the active Platform network: contested - /// submissions and their deadlines must never leak across a - /// Testnet/Mainnet round-trip. + /// UserDefaults key prefixes. A suffix is added for the active + /// Platform network: contested submissions and their deadlines must + /// never leak across a Testnet/Mainnet round-trip. + /// `entriesKeyPrefix` is the multi-label store (canonical label → + /// {submitted, end}); the label/endTime prefixes are the two retired + /// single-slot layouts, kept only for one-time migration. + private static let entriesKeyPrefix = "DWPendingContestedDPNSEntries" private static let pendingLabelKeyPrefix = "DWPendingContestedDPNSLabel" private static let pendingVotingEndTimeKeyPrefix = "DWPendingContestedDPNSVotingEndTime" + /// Dictionary-value field names in the entries store. + private static let submittedField = "submitted" + private static let endField = "end" + /// Protocol vote-poll durations in the Platform v2 settings. The fallback /// starts at OUR submission time, which is at or after the first contender's /// timestamp, and adds a grace period, so it cannot resolve earlier than the @@ -79,21 +88,30 @@ public final class DWContestedNameStatusService: NSObject { // MARK: - Public API - /// Persisted label of the most-recent contested submission, or - /// `nil` if no submission is in flight. Single-writer (this - /// service); single-reader (`DWCurrentUserIdentityInfo` - /// snapshot filter). + /// OLDEST in-flight contested label, or `nil` when none. The setup + /// flow's compatibility view of the store: those surfaces only deal + /// with the user's FIRST username, which is by construction the + /// oldest entry. Multi-label consumers use `pendingLabels`. public var pendingLabel: String? { guard let network = WalletEnvironment.network else { return nil } - return pendingLabel(for: network) + return pendingLabels(for: network).first + } + + /// Every in-flight contested label for the active network, oldest + /// submission first. + public var pendingLabels: [String] { + guard let network = WalletEnvironment.network else { return [] } + return pendingLabels(for: network) } - /// Best-known voting deadline for the active network. Submission writes a - /// conservative fallback immediately; `ContestVoteState.endTime` replaces - /// it once Platform indexes the contest. + /// Best-known voting deadline of the OLDEST entry (see `pendingLabel`). + /// Submission writes a conservative fallback immediately; + /// `ContestVoteState.endTime` replaces it once Platform indexes the + /// contest. public var pendingVotingEndTime: Date? { - guard let network = WalletEnvironment.network else { return nil } - return pendingVotingEndTime(for: network) + guard let network = WalletEnvironment.network, + let label = pendingLabels(for: network).first else { return nil } + return pendingVotingEndTime(label: label, for: network) } /// Coordinator calls this immediately after `registerDpnsName` @@ -113,18 +131,15 @@ public final class DWContestedNameStatusService: NSObject { /// Network-explicit variant used by the registration coordinator. It /// captures the runtime network before any async FFI work, avoiding a /// late completion being written into the newly-selected network. + /// Upserts — an existing entry for the label keeps its original + /// submission time (re-recording from recovery must not reorder). @nonobjc func recordSubmission( label: String, network: Network, submittedAt: Date = Date() ) { - let fallbackEnd = Self.fallbackVotingEndTime( - submittedAt: submittedAt, - network: network) - guard let labelKey = Self.pendingLabelKey(for: network), - let endTimeKey = Self.pendingVotingEndTimeKey(for: network) - else { + guard let key = Self.entriesKey(for: network) else { // A submission is always made by an active wallet, so this cannot // happen — but recording it under no wallet would write a bookmark // nothing can ever own or clear. @@ -132,30 +147,60 @@ public final class DWContestedNameStatusService: NSObject { "🪪 CONTEST-SVC :: cannot record submission with no active wallet") return } - UserDefaults.standard.set(label, forKey: labelKey) - UserDefaults.standard.set(fallbackEnd.timeIntervalSince1970, forKey: endTimeKey) + let fallbackEnd = Self.fallbackVotingEndTime( + submittedAt: submittedAt, + network: network) + var entries = Self.entries(for: network) + let canonical = Self.canonicalLabel(label) + if var existing = entries[canonical] { + existing[Self.endField] = existing[Self.endField] ?? fallbackEnd.timeIntervalSince1970 + entries[canonical] = existing + } else { + entries[canonical] = [ + Self.submittedField: submittedAt.timeIntervalSince1970, + Self.endField: fallbackEnd.timeIntervalSince1970, + ] + } + UserDefaults.standard.set(entries, forKey: key) Self.logger.info( - "🪪 CONTEST-SVC :: recordSubmission label=\(label, privacy: .public) network=\(network.rawValue, privacy: .public) fallbackEnd=\(fallbackEnd.timeIntervalSince1970, privacy: .public)") - } - - /// Cache the real contest deadline once Platform exposes its vote state. - /// It replaces the conservative submission-time estimate. - public func recordVotingEndTime(_ endTime: Date) { - guard let network = WalletEnvironment.network else { return } - recordVotingEndTime(endTime, network: network) + "🪪 CONTEST-SVC :: recordSubmission label=\(canonical, privacy: .public) network=\(network.rawValue, privacy: .public) inFlight=\(entries.count, privacy: .public)") } + /// Cache the real contest deadline for one label once Platform exposes + /// its vote state. It replaces the conservative submission-time + /// estimate. No-op for a label with no bookmark. @nonobjc - func recordVotingEndTime(_ endTime: Date, network: Network) { - guard let endTimeKey = Self.pendingVotingEndTimeKey(for: network) else { return } - UserDefaults.standard.set(endTime.timeIntervalSince1970, forKey: endTimeKey) + func recordVotingEndTime(_ endTime: Date, label: String, network: Network) { + guard let key = Self.entriesKey(for: network) else { return } + var entries = Self.entries(for: network) + let canonical = Self.canonicalLabel(label) + guard var entry = entries[canonical] else { return } + entry[Self.endField] = endTime.timeIntervalSince1970 + entries[canonical] = entry + UserDefaults.standard.set(entries, forKey: key) Self.logger.info( - "🪪 CONTEST-SVC :: authoritative voting end network=\(network.rawValue, privacy: .public) end=\(endTime.timeIntervalSince1970, privacy: .public)") + "🪪 CONTEST-SVC :: authoritative voting end label=\(canonical, privacy: .public) network=\(network.rawValue, privacy: .public) end=\(endTime.timeIntervalSince1970, privacy: .public)") } - /// Clear the pending bookmark. Called by the LOST/pruned branches of + /// Clear ONE label's bookmark — the LOST/pruned branches of /// `DWIdentityRegistrationCoordinator.checkPendingContestResolution()` - /// (and by `finalizeWon` on the WON branch). + /// (and `finalizeWon` on the WON branch). Other in-flight contests + /// keep their bookmarks. + @nonobjc + func clearPending(label: String, for network: Network) { + guard let key = Self.entriesKey(for: network) else { return } + var entries = Self.entries(for: network) + entries.removeValue(forKey: Self.canonicalLabel(label)) + if entries.isEmpty { + UserDefaults.standard.removeObject(forKey: key) + } else { + UserDefaults.standard.set(entries, forKey: key) + } + Self.logger.info("🪪 CONTEST-SVC :: clearPending label=\(Self.canonicalLabel(label), privacy: .public) network=\(network.rawValue, privacy: .public) remaining=\(entries.count, privacy: .public)") + } + + /// Drop EVERY bookmark for the network (all in-flight labels, plus any + /// retired-layout leftovers). public func clearPending() { guard let network = WalletEnvironment.network else { return } clearPending(for: network) @@ -164,6 +209,9 @@ public final class DWContestedNameStatusService: NSObject { @nonobjc func clearPending(for network: Network) { let defaults = UserDefaults.standard + if let key = Self.entriesKey(for: network) { + defaults.removeObject(forKey: key) + } if let labelKey = Self.pendingLabelKey(for: network) { defaults.removeObject(forKey: labelKey) } @@ -174,7 +222,7 @@ public final class DWContestedNameStatusService: NSObject { // leave a pre-scoping value behind for the next read to adopt. defaults.removeObject(forKey: Self.legacyPendingLabelKey(for: network)) defaults.removeObject(forKey: Self.legacyPendingVotingEndTimeKey(for: network)) - Self.logger.info("🪪 CONTEST-SVC :: clearPending network=\(network.rawValue, privacy: .public)") + Self.logger.info("🪪 CONTEST-SVC :: clearPending ALL network=\(network.rawValue, privacy: .public)") } /// Compare DPNS labels in their canonical form. The registration form @@ -185,9 +233,9 @@ public final class DWContestedNameStatusService: NSObject { } /// Objective-C-friendly check used by the legacy DashPay state bridge. + /// True when ANY in-flight contested submission matches `label`. public func isPendingLabel(_ label: String) -> Bool { - guard let pendingLabel else { return false } - return Self.labelsMatch(label, pendingLabel) + pendingLabels.contains { Self.labelsMatch(label, $0) } } private nonisolated static func canonicalLabel(_ label: String) -> String { @@ -222,7 +270,8 @@ public final class DWContestedNameStatusService: NSObject { options.dashpayUsername = username } options.dashpayRegistrationCompleted = true - clearPending(for: network) + // Only the WON label's bookmark clears — other contests stay in flight. + clearPending(label: username, for: network) Self.logger.info("🪪 CONTEST-SVC :: finalizeWon label=\(username, privacy: .public)") DWCurrentUserIdentityInfo.shared.refreshFromSDK() NotificationCenter.default.post( @@ -247,19 +296,77 @@ public final class DWContestedNameStatusService: NSObject { // MARK: - Network-scoped storage + /// All in-flight labels for `network`, oldest submission first. + @nonobjc + func pendingLabels(for network: Network) -> [String] { + Self.entries(for: network) + .sorted { + ($0.value[Self.submittedField] ?? 0) < ($1.value[Self.submittedField] ?? 0) + } + .map(\.key) + } + + /// Compatibility single-label read: the OLDEST in-flight label. @nonobjc func pendingLabel(for network: Network) -> String? { - Self.discardLegacyBookmark(for: network) - guard let key = Self.pendingLabelKey(for: network) else { return nil } - return UserDefaults.standard.string(forKey: key) + pendingLabels(for: network).first } + /// Best-known voting deadline for one label's contest, or nil when the + /// label has no bookmark. + @nonobjc + func pendingVotingEndTime(label: String, for network: Network) -> Date? { + guard let timestamp = Self.entries(for: network)[Self.canonicalLabel(label)]?[Self.endField], + timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: timestamp) + } + + /// Compatibility read: the OLDEST entry's deadline (see `pendingLabel`). @nonobjc func pendingVotingEndTime(for network: Network) -> Date? { - Self.discardLegacyBookmark(for: network) - guard let key = Self.pendingVotingEndTimeKey(for: network) else { return nil } - let timestamp = UserDefaults.standard.double(forKey: key) - return timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil + guard let label = pendingLabels(for: network).first else { return nil } + return pendingVotingEndTime(label: label, for: network) + } + + /// The entries dictionary, after migrating any retired single-slot + /// bookmark into it (one-time: the old keys are deleted on adoption). + private nonisolated static func entries(for network: Network) -> [String: [String: Double]] { + discardLegacyBookmark(for: network) + guard let key = entriesKey(for: network) else { return [:] } + let defaults = UserDefaults.standard + var entries = (defaults.dictionary(forKey: key) as? [String: [String: Double]]) ?? [:] + // Adopt the retired wallet-scoped single-slot layout: same wallet + // scope, so attribution is unambiguous (unlike the legacy unscoped + // bookmark, which is discarded). + if let labelKey = pendingLabelKey(for: network), + let oldLabel = defaults.string(forKey: labelKey) { + let canonical = canonicalLabel(oldLabel) + if entries[canonical] == nil { + let oldEnd = pendingVotingEndTimeKey(for: network) + .map { defaults.double(forKey: $0) } ?? 0 + let end = oldEnd > 0 + ? oldEnd + : fallbackVotingEndTime(submittedAt: Date(), network: network).timeIntervalSince1970 + // Approximate the original submission time from the deadline + // so ordering against newer entries stays sane. + let duration = network == .mainnet ? mainnetFallbackDuration : testnetFallbackDuration + entries[canonical] = [ + submittedField: end - duration - fallbackResolutionGrace, + endField: end, + ] + defaults.set(entries, forKey: key) + logger.info("🪪 CONTEST-SVC :: migrated single-slot bookmark label=\(canonical, privacy: .public)") + } + defaults.removeObject(forKey: labelKey) + if let endKey = pendingVotingEndTimeKey(for: network) { + defaults.removeObject(forKey: endKey) + } + } + return entries + } + + private nonisolated static func entriesKey(for network: Network) -> String? { + scope().map { "\(entriesKeyPrefix).\(networkKey(network)).\($0)" } } nonisolated static func fallbackVotingEndTime( @@ -334,7 +441,9 @@ public final class DWContestedNameStatusService: NSObject { nonisolated static func resetForWipe() { let defaults = UserDefaults.standard for key in defaults.dictionaryRepresentation().keys - where key.hasPrefix(pendingLabelKeyPrefix) || key.hasPrefix(pendingVotingEndTimeKeyPrefix) { + where key.hasPrefix(entriesKeyPrefix) + || key.hasPrefix(pendingLabelKeyPrefix) + || key.hasPrefix(pendingVotingEndTimeKeyPrefix) { defaults.removeObject(forKey: key) } logger.info("🪪 CONTEST-SVC :: cleared all contested bookmarks for wipe") diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index 61167aa94..b2808880f 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -308,12 +308,10 @@ public final class DWCurrentUserIdentityInfo: NSObject { let persistedIdentity = persistedWallet.identities.first(where: { $0.identityId == recoveredIdentityId }) { - let pending = DWContestedNameStatusService.shared.pendingLabel recoveredUsername = [persistedIdentity.mainDpnsName, persistedIdentity.dpnsName] .compactMap { Self.nilIfEmpty($0) } .first(where: { candidate in - guard let pending else { return true } - return candidate != pending && candidate != "\(pending).dash" + !DWContestedNameStatusService.shared.isPendingLabel(candidate) }) } } @@ -490,10 +488,11 @@ public final class DWCurrentUserIdentityInfo: NSObject { // invitation links, and the payment-side username memo. The // service-side bookmark in `DWContestedNameStatusService` // is single-writer/single-reader and cleared on resolution. - let pendingContested = DWContestedNameStatusService.shared.pendingLabel + // EVERY in-flight contested label filters out — the marketplace + // allows several simultaneous requests, each its own vote poll. + let pendingContested = DWContestedNameStatusService.shared.pendingLabels let isPending: (String) -> Bool = { name in - guard let pending = pendingContested else { return false } - return DWContestedNameStatusService.labelsMatch(name, pending) + pendingContested.contains { DWContestedNameStatusService.labelsMatch(name, $0) } } if let managed = try? wallet.managedIdentity(identityId: identityId) { @@ -671,14 +670,12 @@ final class DWSameSeedIdentityRecoveryCoordinator { let contested = try wallet .managedIdentity(identityId: identityId) .getContestedDpnsNames() - let pending = DWContestedNameStatusService.shared.pendingLabel - if let recoveredPending = contested.min(), - pending != recoveredPending, - pending != "\(recoveredPending).dash" { - // A second install has no local submission - // bookmark. Reconstruct it from Platform so - // the pre-vote DPNS document cannot be - // mistaken for ownership. + // A second install has no local submission + // bookmarks. Reconstruct one per still-voting + // label from Platform so the pre-vote DPNS + // documents cannot be mistaken for ownership. + for recoveredPending in contested + where !DWContestedNameStatusService.shared.isPendingLabel(recoveredPending) { DWContestedNameStatusService.shared.recordSubmission( label: recoveredPending) } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift index 67ea826f4..3b53e0194 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift @@ -783,7 +783,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { identityId: identityId, label: username) { DWContestedNameStatusService.shared - .recordVotingEndTime(voteState.endTime, network: network) + .recordVotingEndTime(voteState.endTime, label: username, network: network) } } catch { Self.logger.warning("🪪 IDENT-COORD :: initial contest vote-state fetch failed: \(String(describing: error), privacy: .public)") @@ -929,7 +929,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { /// Deliberate omission: no in-session timer — appear/foreground covers /// the testnet (~90 min) and mainnet (~2 week) voting windows. func checkPendingContestResolution() { - guard DWContestedNameStatusService.shared.pendingLabel != nil else { return } + guard !DWContestedNameStatusService.shared.pendingLabels.isEmpty else { return } guard contestResolutionTask == nil else { return } // single-flight switch phase { case .preparingKeys, .inFlight: @@ -946,9 +946,9 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { private enum ContestOutcome { case won, lost } private func runPendingContestResolution() async { - guard let expectedNetwork = WalletEnvironment.network, - let label = DWContestedNameStatusService.shared - .pendingLabel(for: expectedNetwork) else { return } + guard let expectedNetwork = WalletEnvironment.network else { return } + let labels = DWContestedNameStatusService.shared.pendingLabels(for: expectedNetwork) + guard !labels.isEmpty else { return } // Bounded wait for host hydration — the Home-appear trigger can // fire before SwiftDashSDKWalletRuntime finishes starting. Give up @@ -976,11 +976,28 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { return } + // Every in-flight contest resolves independently — a per-label + // failure only skips that label for this pass. + for label in labels { + await resolvePendingContest( + label: label, + wallet: wallet, + identityId: identityId, + expectedNetwork: expectedNetwork) + } + } + + private func resolvePendingContest( + label: String, + wallet: ManagedPlatformWallet, + identityId: Data, + expectedNetwork: Network + ) async { let outcome: ContestOutcome do { if let state = try await wallet.fetchContestVoteState(identityId: identityId, label: label) { DWContestedNameStatusService.shared - .recordVotingEndTime(state.endTime, network: expectedNetwork) + .recordVotingEndTime(state.endTime, label: label, network: expectedNetwork) switch state.winner { case .none: return // still voting @@ -1008,7 +1025,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { // not consulted: preregistration puts the label there before // voting and therefore cannot prove ownership. guard let votingEnd = DWContestedNameStatusService.shared - .pendingVotingEndTime(for: expectedNetwork), + .pendingVotingEndTime(label: label, for: expectedNetwork), Date() >= votingEnd else { return } @@ -1016,18 +1033,20 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { outcome = (resolvedOwner == identityId) ? .won : .lost } } catch { - Self.logger.warning("🪪 IDENT-COORD :: contest check failed (retry on next trigger): \(String(describing: error), privacy: .public)") + Self.logger.warning("🪪 IDENT-COORD :: contest check for \(label, privacy: .public) failed (retry on next trigger): \(String(describing: error), privacy: .public)") return } - // Freshness guard: a new submission may have replaced the bookmark - // while our awaits were in flight. Same MainActor stretch as the - // mutation below, so it's atomic against recordSubmission. + // Freshness guard: the bookmark may have been cleared or the + // network switched while our awaits were in flight. Same MainActor + // stretch as the mutation below, so it's atomic against + // recordSubmission. guard WalletEnvironment.network == expectedNetwork, SwiftDashSDKHost.shared.runningNetwork == expectedNetwork, - DWContestedNameStatusService.shared.pendingLabel(for: expectedNetwork) == label + DWContestedNameStatusService.shared.pendingLabels(for: expectedNetwork) + .contains(where: { DWContestedNameStatusService.labelsMatch($0, label) }) else { - Self.logger.info("🪪 IDENT-COORD :: contest check became stale after network/submission change") + Self.logger.info("🪪 IDENT-COORD :: contest check for \(label, privacy: .public) became stale after network/submission change") return } switch outcome { @@ -1037,8 +1056,8 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { username: label, network: expectedNetwork) case .lost: - Self.logger.info("🪪 IDENT-COORD :: contest lost/locked for \(label, privacy: .public) — clearing bookmark; a new registration attempt is viable") - DWContestedNameStatusService.shared.clearPending(for: expectedNetwork) + Self.logger.info("🪪 IDENT-COORD :: contest lost/locked for \(label, privacy: .public) — clearing its bookmark; a new registration attempt is viable") + DWContestedNameStatusService.shared.clearPending(label: label, for: expectedNetwork) } } @@ -1069,7 +1088,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { // calls `DWContestedNameStatusService.finalizeWon(username:)` // to perform them when the vote resolves in our favor. if case .completed = newPhase, let username = currentUsername { - let isContestedSubmission = DWContestedNameStatusService.shared.pendingLabel == username + let isContestedSubmission = DWContestedNameStatusService.shared.isPendingLabel(username) if isContestedSubmission { Self.logger.info("🪪 IDENT-COORD :: completed (contested) — deferring DWGlobalOptions mirror writes") } else { diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift index 8059e4b65..0fb8e65d4 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift @@ -68,7 +68,6 @@ struct UsernameMarketplaceService { case invalidPrice case authCancelled case authFailed - case contestInProgress(pendingLabel: String) var errorDescription: String? { switch self { @@ -78,10 +77,6 @@ struct UsernameMarketplaceService { return NSLocalizedString("Short names are decided by a network vote — use Request Username instead.", comment: "Username marketplace") case .invalidRecipient: return NSLocalizedString("The recipient identity couldn't be resolved.", comment: "Username marketplace") - case let .contestInProgress(pendingLabel): - return String.localizedStringWithFormat( - NSLocalizedString("Your request for “%@” is still in the network vote. Wait for that vote to finish before requesting another name.", comment: "Username marketplace: a second contested request is refused while one is pending"), - pendingLabel) case .invalidPrice: return NSLocalizedString("This price is too high to list.", comment: "Username marketplace: listing price exceeds the representable maximum") case .authCancelled: @@ -220,15 +215,10 @@ struct UsernameMarketplaceService { /// submission is bookmarked in `DWContestedNameStatusService` so the /// not-yet-owned label stays out of every username surface /// (`DWCurrentUserIdentityInfo`'s pending filter) and the Home-appear - /// reconciliation resolves the eventual win/loss. The bookmark is - /// single-slot, so only ONE contested request may be in flight at a - /// time — a second submission would overwrite the first's bookmark, - /// leaking the still-voting label into username surfaces and - /// orphaning its reconciliation. This method refuses (typed - /// `contestInProgress`) while any bookmark is pending. - /// TODO(contest-multi): lift the one-at-a-time limit by making the - /// bookmark store, `DWCurrentUserIdentityInfo`'s filter, and - /// `checkPendingContestResolution` multi-label. + /// reconciliation resolves the eventual win/loss. The bookmark store + /// tracks every in-flight label independently, so any number of + /// contested requests can run at once — each is its own vote poll + /// with its own vote-resolution fund. /// /// Returns the authoritative voting end time when Platform has /// already indexed the contest, nil while indexing lags. @@ -242,9 +232,6 @@ struct UsernameMarketplaceService { guard let network = WalletEnvironment.network else { throw ServiceError.noIdentity } - if let pending = DWContestedNameStatusService.shared.pendingLabel(for: network) { - throw ServiceError.contestInProgress(pendingLabel: pending) - } try await authorize() _ = try await wallet.registerDpnsName( identityId: identityId, @@ -263,7 +250,7 @@ struct UsernameMarketplaceService { var endTime: Date? if let state = try? await wallet.fetchContestVoteState(identityId: identityId, label: label) { endTime = state.endTime - DWContestedNameStatusService.shared.recordVotingEndTime(state.endTime, network: network) + DWContestedNameStatusService.shared.recordVotingEndTime(state.endTime, label: label, network: network) } return endTime } diff --git a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift index 566e21849..bf783ec9b 100644 --- a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift @@ -55,6 +55,10 @@ final class UsernameMarketplaceViewModel: ObservableObject { @Published var isSearching = false @Published var isLoadingMine = false @Published var isPerformingAction = false + /// Non-nil while a trade action runs: what the wallet is doing right + /// now ("Submitting your username request…"). Drives the blocking + /// spinner overlay. + @Published var activityMessage: String? @Published var errorMessage: String? /// Transient success line ("hilawe listed for 0.05 DASH"). @Published var successMessage: String? @@ -187,14 +191,21 @@ final class UsernameMarketplaceViewModel: ObservableObject { /// Run one PIN-gated trade action with shared progress/error/success /// handling, then refresh both lists so the new sale state renders. + /// `progressText` drives the blocking activity overlay on the + /// marketplace screen — the sheets dismiss themselves as the action + /// starts, and a Platform transition takes seconds; without it the + /// screen sat silent until the success banner. func perform( + progressText: String, successText: String, _ operation: @escaping () async throws -> Void ) { guard !isPerformingAction else { return } isPerformingAction = true + withAnimation { activityMessage = progressText } Task { [weak self] in guard let self else { return } + defer { withAnimation { self.activityMessage = nil } } do { try await operation() isPerformingAction = false @@ -277,6 +288,32 @@ struct UsernameMarketplaceScreen: View { RegisterNameSheet(label: candidate.label, viewModel: viewModel) .presentationDetents([.medium, .large]) } + .overlay { + // Blocking activity overlay while a trade transition is in + // flight — the action sheets dismiss themselves as the action + // starts, so this is the only feedback until success/error. + // (The PIN prompt is a UIKit modal and presents above it.) + if let activity = viewModel.activityMessage { + ZStack { + Color.black.opacity(0.3).ignoresSafeArea() + VStack(spacing: 14) { + SwiftUI.ProgressView() + .scaleEffect(1.3) + Text(activity) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.dash.secondaryBackground) + .shadow(color: Color.dash.shadow, radius: 20, x: 0, y: 6)) + } + .transition(.opacity) + } + } .overlay(alignment: .bottom) { if let success = viewModel.successMessage { Label(success, systemImage: "checkmark.circle.fill") @@ -920,7 +957,9 @@ private struct MarketplaceNameDetailSheet: View { viewModel.errorMessage = NSLocalizedString("This username is not for sale.", comment: "Username marketplace") return } - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Completing your purchase…", comment: "Username marketplace: activity overlay while the purchase transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("%@ is now yours", comment: "Username marketplace: purchase success"), label)) { try await viewModel.service.purchase(name: label, expectedPriceCredits: expected) @@ -939,7 +978,9 @@ private struct MarketplaceNameDetailSheet: View { titleVisibility: .visible ) { Button(NSLocalizedString("Remove From Sale", comment: "Username marketplace"), role: .destructive) { - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Removing the listing…", comment: "Username marketplace: activity overlay while the delist transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("%@ is no longer for sale", comment: "Username marketplace: delist success"), label)) { try await viewModel.service.removeFromSale(name: label) @@ -1092,7 +1133,9 @@ private struct SetNamePriceSheet: View { Button { guard let duffs = priceDuffs else { return } - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Listing for sale…", comment: "Username marketplace: activity overlay while the set-price transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("%1$@ listed for %2$@ DASH", comment: "Username marketplace: listing success — name, then price"), label, duffs.dashAmount.formattedDashAmountWithoutCurrencySymbol)) { @@ -1254,7 +1297,9 @@ private struct TransferNameSheet: View { } private func run(recipientBase58: String) { - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Transferring the username…", comment: "Username marketplace: activity overlay while the transfer transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("%@ transferred", comment: "Username marketplace: transfer success"), label)) { try await viewModel.service.transfer(name: label, toIdentityBase58: recipientBase58) @@ -1287,16 +1332,6 @@ private struct RegisterNameSheet: View { viewModel.hasRequestedContest(for: label) } - /// A DIFFERENT label's contested request is still in the network - /// vote. The reconciliation bookmark is single-slot, so a second - /// request is refused rather than silently orphaning the first - /// (the service enforces the same rule). - private var pendingOtherContestLabel: String? { - guard let pending = DWContestedNameStatusService.shared.pendingLabel, - !DWContestedNameStatusService.labelsMatch(pending, label) else { return nil } - return pending - } - /// Whether the identity balance covers the vote-resolution fund — /// the same check `requestCostCard` renders, reused to keep the /// submit button from offering a request that must fail. @@ -1339,7 +1374,9 @@ private struct RegisterNameSheet: View { .padding(.top, 8) confirmButton(NSLocalizedString("Register", comment: "Username marketplace: confirm register button")) { - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Registering the username…", comment: "Username marketplace: activity overlay while the registration transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("%@ registered", comment: "Username marketplace: registration success"), label)) { try await viewModel.service.register(label: label) @@ -1391,12 +1428,6 @@ private struct RegisterNameSheet: View { icon: "hourglass", text: NSLocalizedString("You already requested this name — the network vote is in progress. You'll find it under My Names.", comment: "Username marketplace: contested request already submitted by this identity")) contestVotesCard - } else if let pending = pendingOtherContestLabel { - statusCallout( - icon: "hourglass", - text: String.localizedStringWithFormat( - NSLocalizedString("Your request for “%@” is still in the network vote. Wait for that vote to finish before requesting another name.", comment: "Username marketplace: a second contested request is refused while one is pending"), - pending)) } else if precheck == nil { SwiftUI.ProgressView() .padding(.top, 20) @@ -1426,7 +1457,9 @@ private struct RegisterNameSheet: View { .padding(.top, 14) confirmButton(NSLocalizedString("Request Username", comment: "Username marketplace: confirm contested request button")) { - viewModel.perform(successText: String.localizedStringWithFormat( + viewModel.perform( + progressText: NSLocalizedString("Submitting your username request…", comment: "Username marketplace: activity overlay while the contested request transition runs"), + successText: String.localizedStringWithFormat( NSLocalizedString("Request for %@ submitted — masternodes now vote on it", comment: "Username marketplace: contested request success"), label)) { try await viewModel.service.requestContestedName(label: label) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index b4ec5f612..6b3dfb7bb 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -785,6 +785,24 @@ /* Username marketplace: contested request line — voting deadline */ "Network vote ends %@" = "Network vote ends %@"; +/* Username marketplace: activity overlay while the purchase transition runs */ +"Completing your purchase…" = "Completing your purchase…"; + +/* Username marketplace: activity overlay while the delist transition runs */ +"Removing the listing…" = "Removing the listing…"; + +/* Username marketplace: activity overlay while the set-price transition runs */ +"Listing for sale…" = "Listing for sale…"; + +/* Username marketplace: activity overlay while the transfer transition runs */ +"Transferring the username…" = "Transferring the username…"; + +/* Username marketplace: activity overlay while the registration transition runs */ +"Registering the username…" = "Registering the username…"; + +/* Username marketplace: activity overlay while the contested request transition runs */ +"Submitting your username request…" = "Submitting your username request…"; + /* Username marketplace: contest tallies card title */ "Network vote so far" = "Network vote so far"; @@ -5201,9 +5219,6 @@ /* Username marketplace: contested request explainer, paragraph 2 */ "Your request enters a public vote by masternodes for about two weeks. Others can request the same name; when the vote ends, the name goes to the winner — or to nobody, if the network votes to lock it." = "Your request enters a public vote by masternodes for about two weeks. Others can request the same name; when the vote ends, the name goes to the winner — or to nobody, if the network votes to lock it."; -/* Username marketplace: a second contested request is refused while one is pending */ -"Your request for “%@” is still in the network vote. Wait for that vote to finish before requesting another name." = "Your request for “%@” is still in the network vote. Wait for that vote to finish before requesting another name."; - /* Usernames */ "Your request was cancelled" = "Your request was cancelled";