From 2d64ee59327e855a66047caa67f4eb77f852367d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 19:57:07 +0700 Subject: [PATCH 1/2] feat(dashpay): any number of simultaneous contested name requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-contested-request-at-a-time refusal existed only because the reconciliation bookmark was single-slot; the protocol itself runs one independent vote poll (and vote-resolution fund) per label. The store is now a per-(network, wallet) dictionary — canonical label → {submittedAt, votingEnd} — and the guard is gone: - DWContestedNameStatusService: pendingLabels / per-label votingEndTime, recordSubmission upserts, recordVotingEndTime and clearPending are per-label, finalizeWon clears only the won label. pendingLabel / pendingVotingEndTime remain as OLDEST-entry conveniences for the setup-flow surfaces (which only ever deal with the first username), so those consumers are unchanged. The retired single-slot bookmark migrates into the dictionary on first read; the unscoped legacy bookmark is still discarded. resetForWipe covers the new key prefix. - DWCurrentUserIdentityInfo: the username-surface filter and the recovered-username pick exclude EVERY in-flight label; identity recovery rebuilds a bookmark per still-voting contested label instead of only the lexicographic minimum. - DWIdentityRegistrationCoordinator: checkPendingContestResolution resolves every in-flight label independently — a per-label failure skips only that label; won/lost clears only its own bookmark. - UsernameMarketplaceService: contestInProgress guard and error case removed; RegisterNameSheet's wait-for-the-other-vote branch removed. Co-Authored-By: Claude Fable 5 --- .../DWContestedNameStatusService.swift | 217 +++++++++++++----- .../Identity/DWCurrentUserIdentityInfo.swift | 25 +- .../DWIdentityRegistrationCoordinator.swift | 51 ++-- .../UsernameMarketplaceService.swift | 23 +- .../UsernameMarketplaceScreen.swift | 16 -- DashWallet/en.lproj/Localizable.strings | 3 - 6 files changed, 214 insertions(+), 121 deletions(-) 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..e00d634ac 100644 --- a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift @@ -1287,16 +1287,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. @@ -1391,12 +1381,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) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index b4ec5f612..44f736cb5 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -5201,9 +5201,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"; From dd33d9de376fac636b3a1f1c87a8255a0e12634b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 10 Aug 2026 20:08:12 +0700 Subject: [PATCH 2/2] feat(dashpay): blocking activity overlay while a trade transition runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the PIN the marketplace sat silent for the seconds a Platform transition takes — the action sheets dismiss themselves as the action starts, so nothing said work was happening until the success banner. perform() now takes a progressText and drives a blocking spinner overlay on the marketplace screen ("Submitting your username request…", "Listing for sale…", "Completing your purchase…", …), shown until the action succeeds or fails. The PIN prompt presents above it. Co-Authored-By: Claude Fable 5 --- .../UsernameMarketplaceScreen.swift | 61 +++++++++++++++++-- DashWallet/en.lproj/Localizable.strings | 18 ++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift index e00d634ac..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) @@ -1329,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) @@ -1410,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 44f736cb5..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";