diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift index 67ea826f4..72298711f 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift @@ -864,31 +864,6 @@ final class DWIdentityRegistrationCoordinator: ObservableObject { } } - /// Whether a previous contest for `label` ended in a masternode LOCK. - /// - /// A locked label reads as AVAILABLE to `dpnsCheckAvailability` β€” no - /// identity owns the domain document, because the vote decided that nobody - /// gets it β€” but the vote poll rejects every new submission, so the state - /// transition fails at broadcast with `vote_poll_status: Locked`. Only a - /// contested-eligible label can be in this state. - /// - /// Returns false when the state cannot be determined: the submit-time - /// transition stays the authority, and a failed query must not block a name - /// that is very likely fine. - func isContestedNameLocked(_ label: String) async -> Bool { - guard let sdk = SwiftDashSDKHost.shared.sdk else { return false } - do { - // `winner` is "LOCKED", a base58 identity id, or absent/null while - // the contest is unresolved (rs-sdk-ffi `dpns/queries/contested.rs`). - let state = try await sdk.dpnsGetContestedVoteState(name: label) - return (state["winner"] as? String) == "LOCKED" - } catch { - Self.logger.info( - "πŸͺͺ IDENT-COORD :: contested lock check unavailable for \(label, privacy: .public): \(String(describing: error), privacy: .public)") - return false - } - } - /// Whether the active wallet has already paid for a Core-funded /// identity registration that still needs to finish. Used by the /// create-username screen to bypass the balance gate and present a diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift index 94ead38be..d85df9fd9 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift @@ -112,9 +112,14 @@ struct UsernameMarketplaceService { } /// Authoritative live state of one exact name; nil = unregistered. + /// The lookup keys on the NORMALIZED label, so the typed form is + /// normalized here first (idempotent for already-normalized input β€” + /// same trap as `contestPrecheck`, where "Greg" missed "greg"). func nameState(_ name: String) async throws -> DpnsMarketplaceName? { guard let wallet = SwiftDashSDKHost.shared.wallet else { return nil } - return try await wallet.dpnsMarketplaceNameState(name: name) + let normalized = (try? SwiftDashSDKHost.shared.sdk?.dpnsNormalizeLabel(name)) + .flatMap { $0 } ?? name.lowercased() + return try await wallet.dpnsMarketplaceNameState(name: normalized) } /// Full trade timeline (registration, listings, purchases with @@ -297,8 +302,9 @@ struct UsernameMarketplaceService { /// No contest exists β€” a request starts a fresh vote. case fresh /// An active vote already holds this label; a request joins it - /// as another contender. - case activeContest(contenders: Int) + /// as another contender. `endsAt` is the poll deadline when the + /// current-contests listing reports one, nil when it doesn't. + case activeContest(contenders: Int, endsAt: Date?) /// A past vote locked the label β€” nobody can register it. case locked /// The query failed; state can't be determined. @@ -307,8 +313,13 @@ struct UsernameMarketplaceService { func contestPrecheck(label: String) async -> ContestPrecheck { guard let sdk = SwiftDashSDKHost.shared.sdk else { return .unknown } + // The vote-poll index is keyed by the NORMALIZED label (lowercase + + // homograph folding). rs-sdk builds the query key from this argument + // verbatim, so the typed form must be normalized here or "Greg" + // reports fresh while "greg" is mid-vote. + let normalized = (try? sdk.dpnsNormalizeLabel(label)) ?? label.lowercased() do { - let state = try await sdk.dpnsGetContestedVoteState(name: label) + let state = try await sdk.dpnsGetContestedVoteState(name: normalized) if state["winner"] is String { // "LOCKED" or a winning identity's base58 id. Either way // the vote is over and nobody can register the label, so @@ -316,7 +327,14 @@ struct UsernameMarketplaceService { return .locked } let contenders = (state["contenders"] as? [[String: Any]]) ?? [] - return contenders.isEmpty ? .fresh : .activeContest(contenders: contenders.count) + if contenders.isEmpty { return .fresh } + // The vote-state payload carries no deadline, so ask the + // current-contests listing (normalized name β†’ TimestampMillis) + // for it. Best-effort: a failed or incomplete listing just + // leaves the deadline unknown. + let endsAt = (try? await sdk.dpnsGetCurrentContests())?[normalized] + .map { Date(timeIntervalSince1970: Double($0) / 1000) } + return .activeContest(contenders: contenders.count, endsAt: endsAt) } catch { // rs-sdk reports "no contest" as an error rather than an // empty result; a missing contest is the normal fresh case. @@ -338,6 +356,20 @@ struct UsernameMarketplaceService { DWContestedNameStatusService.isContestedLabel(label) } + /// Until when NEW contenders may join a contest, derived from its + /// authoritative voting end time. Mirrors rs-platform-version + /// `VotingValidationVersions` (v3): contenders may join for + /// `allow_other_contenders_time` after the FIRST request β€” mainnet + /// 1 week of the 2-week poll, testing environments 45 minutes of the + /// 90-minute poll. In both, joining closes (poll βˆ’ join window) + /// before the end: 1 week on mainnet, 45 minutes on testnet. + static func contenderJoinDeadline(voteEnd: Date) -> Date { + let closedBeforeEnd: TimeInterval = WalletEnvironment.isMainnet + ? 7 * 24 * 60 * 60 + : 45 * 60 + return voteEnd.addingTimeInterval(-closedBeforeEnd) + } + /// The protocol's contested-document vote-resolution fund: what a /// contested request locks from the identity balance on top of the /// normal registration fee. Mirrors diff --git a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift index b8915d315..09e8ce9f0 100644 --- a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift +++ b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewController.swift @@ -22,6 +22,12 @@ import DashUIKit class CreateUsernameViewController: UIViewController { @objc var completionHandler: ((Bool) -> ())? + /// Set before presentation by the readiness-interstitial `onProceed` + /// paths: the user has already been through the shielded checklist + /// (or its explicit transparent escape), so the form skips the + /// Private registration teaser. Read once in `viewDidLoad`. + @objc var suppressShieldedHint: Bool = false + /// Normalized invitation URI (see `DWInvitationLinkNormalizer`) /// when this form is claiming a DIP-13 invitation; nil for the /// regular self-funded registration. @@ -50,7 +56,8 @@ class CreateUsernameViewController: UIViewController { let content = CreateUsernameView( invitationURI: invitationURI, - definedUsername: definedUsername + definedUsername: definedUsername, + suppressShieldedHint: suppressShieldedHint ) { let navigationController = self.navigationController #if DASHPAY @@ -121,6 +128,11 @@ struct CreateUsernameView: View { var invitationURI: String? = nil /// Username prefill carried by the deep link (`definedUsername`). var definedUsername: String? = nil + /// True when the user arrived through the readiness interstitial β€” + /// they either chose the explicit "Use transparent balance instead" + /// escape or already worked through the shielded checklist there, + /// so the form must not re-tease private registration. + var suppressShieldedHint: Bool = false var finish: () -> Void var body: some View { @@ -167,16 +179,27 @@ struct CreateUsernameView: View { // Contested-name warning. Shown when the typed label is // ≀19 chars + only [a-zA-Z0-9-] AND otherwise passes the - // local validators. Submitting a contested name triggers - // masternode voting (~45 min testnet, ~2 weeks mainnet) - // before the name is actually claimed β€” the user gets a - // separate confirmation alert on Continue. Mirrors the - // example app's `RegisterNameView.swift:277-293` styling. - if viewModel.isContestedCandidate { + // local validators β€” EXCEPT when the name is plainly taken: + // an owned name will never go to a vote, so the warning + // would contradict the "taken" row (`showContestedWarning`). + // Submitting a contested name triggers masternode voting + // (~45 min testnet, ~2 weeks mainnet) before the name is + // actually claimed β€” the user gets a separate confirmation + // alert on Continue. Mirrors the example app's + // `RegisterNameView.swift:277-293` styling. + if viewModel.showContestedWarning { contestedNameWarning .padding(.top, 20) } + // Taken-but-listed pointer: the owner has put the name up for + // sale in the Username Marketplace, so "taken" isn't the end + // of the road. + if let salePriceDuffs = viewModel.takenNameSalePriceDuffs { + forSaleHint(priceDuffs: salePriceDuffs) + .padding(.top, 20) + } + if viewModel.hasPendingRegistrationRecovery { registrationRecoveryBanner .padding(.top, 20) @@ -195,9 +218,12 @@ struct CreateUsernameView: View { // funding path is NOT yet available (needs funds / maturing / // pool below the consensus minimum) so the user learns what // the wait is for without being blocked β€” the transparent - // sources below remain an explicit choice. + // sources below remain an explicit choice. Suppressed when the + // user already answered this question on the readiness + // interstitial (`suppressShieldedHint`). if !viewModel.isInvitationMode, - !viewModel.hasPendingRegistrationRecovery { + !viewModel.hasPendingRegistrationRecovery, + !suppressShieldedHint { shieldedReadinessHint } @@ -284,13 +310,30 @@ struct CreateUsernameView: View { isPresented: $showContestedConfirmation ) { Button(NSLocalizedString("Submit anyway", comment: "Usernames"), role: .destructive) { + // The alert can sit open across the join-window boundary. + // Re-check at the moment of confirmation: a submission the + // network would refuse is replaced by a fresh availability + // answer (which shows the join-closed state) instead of a + // broadcast failure. + if viewModel.activeContestContenders != nil, viewModel.activeContestJoinClosed { + showContestedConfirmation = false + viewModel.refreshRegistrationRecoveryState() + return + } performSubmit() } Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } } message: { - Text(NSLocalizedString( - "This name requires voting. Your Dash will be locked until voting completes.", - comment: "Usernames")) + // The join-the-vote wording only holds while the join window is + // still open; past it (or with no known contest) the generic + // contested message is the honest one. + Text(viewModel.activeContestContenders != nil && !viewModel.activeContestJoinClosed + ? NSLocalizedString( + "A vote for this name is already in progress β€” your request will join it as a contender. Your Dash will be locked until voting completes.", + comment: "Usernames") + : NSLocalizedString( + "This name requires voting. Your Dash will be locked until voting completes.", + comment: "Usernames")) } .alert( NSLocalizedString("Username registered", comment: "Usernames"), @@ -439,23 +482,68 @@ struct CreateUsernameView: View { } } + /// Deadline as "Today 20:33"-style text (DWDateFormatter's relative + /// short date + time). Testnet contest windows are minutes long, so + /// the time matters; a locale-formatted full date alone would bury it. + private static func contestDeadlineText(_ date: Date) -> String { + let formatter = DWDateFormatter.sharedInstance + return "\(formatter.shortStringFromDate(date)) \(formatter.timeOnly(from: date))" + } + /// Orange warning callout shown above the Continue button when /// the typed name is contested-eligible. Styled to match the - /// example app's `RegisterNameView.swift:277-293`. + /// example app's `RegisterNameView.swift:277-293`. When the view + /// model reports a vote already running for this name + /// (`activeContestContenders`), the copy switches from "requires + /// a vote" to "a vote is in progress β€” a request joins it", with + /// the deadline when the network reports one; past the deadline + /// it reports the vote as ended and finalizing instead. private var contestedNameWarning: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") + let voteInProgress = viewModel.activeContestContenders != nil + let title: String + let body: String + if voteInProgress, viewModel.activeContestHasEnded { + title = NSLocalizedString("Vote ended", comment: "Usernames") + body = NSLocalizedString( + "The masternode vote for this name has ended and the result is being finalized. Check back soon.", + comment: "Usernames") + } else if voteInProgress, let endsAt = viewModel.activeContestEndsAt, viewModel.activeContestJoinClosed { + title = NSLocalizedString("Vote in progress", comment: "Usernames") + body = String.localizedStringWithFormat( + NSLocalizedString( + "A masternode vote for this name is in progress β€” voting ends around %@. New contenders can no longer join this vote.", + comment: "Usernames"), + Self.contestDeadlineText(endsAt)) + } else if voteInProgress, let endsAt = viewModel.activeContestEndsAt { + title = NSLocalizedString("Vote in progress", comment: "Usernames") + body = String.localizedStringWithFormat( + NSLocalizedString( + "A masternode vote for this name is already in progress β€” voting ends around %@. Submitting a request joins the vote as a contender.", + comment: "Usernames"), + Self.contestDeadlineText(endsAt)) + } else if voteInProgress { + title = NSLocalizedString("Vote in progress", comment: "Usernames") + body = NSLocalizedString( + "A masternode vote for this name is already in progress. Submitting a request joins the vote as a contender.", + comment: "Usernames") + } else { + title = NSLocalizedString("Contested name", comment: "Usernames") + body = NSLocalizedString( + "This name requires a masternode vote.", + comment: "Usernames") + } + return HStack(alignment: .top, spacing: 12) { + Image(systemName: voteInProgress ? "person.2.fill" : "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.system(size: 20)) VStack(alignment: .leading, spacing: 4) { - Text(NSLocalizedString("Contested name", comment: "Usernames")) + Text(title) .font(.subheadline.bold()) .foregroundColor(.orange) - Text(NSLocalizedString( - "This name requires a masternode vote.", - comment: "Usernames")) + Text(body) .font(.caption) .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) } } .padding(12) @@ -464,13 +552,34 @@ struct CreateUsernameView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } - /// Encapsulates the submit-to-bridge dance so both the direct - /// Continue path and the contested-name alert's "Submit anyway" - /// button can share the code. Writes the funding-source pick - /// into the bridge right before submit. The bridge resets to - /// `.core` on every terminal phase, so a stale picker value - /// can't leak into a future attempt; this single write is the - /// only synchronization needed. + /// Blue informational callout when the typed name is taken but its + /// owner has listed it in the Username Marketplace: buying it there + /// is the actual way to get this name. + private func forSaleHint(priceDuffs: UInt64) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "tag.fill") + .foregroundColor(.dash.blue) + .font(.system(size: 20)) + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("For sale", comment: "Usernames")) + .font(.subheadline.bold()) + .foregroundColor(.dash.blue) + Text(String.localizedStringWithFormat( + NSLocalizedString( + "The owner of this username has listed it in the Username Marketplace for %@ Dash. You can buy it there instead.", + comment: "Usernames"), + priceDuffs.dashAmount.formattedDashAmountWithoutCurrencySymbol)) + .font(.caption) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.dash.blue.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + /// Invitation-claim funding banner (invitation mode only). private var invitationFundingBanner: some View { HStack(alignment: .top, spacing: 12) { @@ -505,6 +614,13 @@ struct CreateUsernameView: View { } } + /// Encapsulates the submit-to-bridge dance so both the direct + /// Continue path and the contested-name alert's "Submit anyway" + /// button can share the code. Writes the funding-source pick + /// into the bridge right before submit. The bridge resets to + /// `.core` on every terminal phase, so a stale picker value + /// can't leak into a future attempt; this single write is the + /// only synchronization needed. private func performSubmit() { if !viewModel.isInvitationMode { DWIdentityRegistrationBridge.shared.preferredFundingSource = @@ -648,6 +764,14 @@ struct CreateUsernameView: View { "Username locked by masternode vote β€” it cannot be registered", comment: "Usernames") } + // Mid-vote with the join window closed β€” also not "taken": + // the vote decides the owner, and no request can be made + // until it resolves. The orange callout carries the detail. + if viewModel.activeContestContenders != nil { + return NSLocalizedString( + "Username is in a network vote β€” new contenders can no longer join", + comment: "Usernames") + } return NSLocalizedString("Username taken", comment: "Usernames") case .error: return NSLocalizedString("Validating username failed", comment: "Usernames") diff --git a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift index 6b0e00ddb..5dfd91440 100644 --- a/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift +++ b/DashWallet/Sources/UI/DashPay/Setup/CreateUsername/CreateUsernameViewModel.swift @@ -49,19 +49,36 @@ struct CreateUsernameUIState { class CreateUsernameViewModel: ObservableObject { private var cancellableBag = Set() private let prefs = UsernamePrefs.shared + /// Stateless facade, instantiated per view model by design (see its + /// type doc). Used here for `contestPrecheck` β€” the one vote-state + /// query that distinguishes a locked name from one mid-vote. + private let marketplaceService = UsernameMarketplaceService() private let illegalChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-").inverted private var submittedRegistrationUsername: String? private var didNotifyRegistrationStarted = false private var onRegistrationStarted: (@MainActor () -> Void)? - /// In-flight DPNS availability check. Cancelled and replaced on - /// every revalidation so only the newest input hits the network. + /// In-flight DPNS availability check. Cancelled and replaced when + /// the typed label changes, so only the newest input hits the network. private var availabilityCheckTask: Task? + /// The trimmed label the current availability-check state belongs to, + /// whether still in flight or already answered. Revalidations for the + /// SAME label β€” balance and readiness publishes fire constantly while + /// syncing β€” reuse the existing answer instead of hitting DAPI again. + /// Cleared on screen appear (`refreshRegistrationRecoveryState`) so + /// every visit re-verifies once. + private var availabilityCheckLabel: String? /// One-shot revalidation alarm for a `.maturing` shielded snapshot. /// The shared readiness service arms its own flip timer only for /// the STANDARD denomination; a contested name's (0.25 DASH) ready /// moment can be later, so the form re-validates itself at the /// snapshot's own `readyAt`. Re-armed on every validation. private var shieldedMaturityRevalidationTask: Task? + /// One-shot re-check at the active contest's next boundary β€” the + /// join-window close, then the vote end. The per-label answer cache + /// deliberately keeps the verdict stable while the user sits on the + /// screen; these two moments are exactly when it goes stale, so the + /// form re-queries then instead of letting a submit fail at broadcast. + private var contestBoundaryRevalidationTask: Task? /// Mirrors the legacy `VALIDATION_DEBOUNCE_DELAY` /// (DWCheckExistenceUsernameValidationRule.m). private static let availabilityDebounceNanos: UInt64 = 400_000_000 @@ -94,6 +111,60 @@ class CreateUsernameViewModel: ObservableObject { /// a name that can only fail at broadcast. @Published private(set) var isLockedContestedName: Bool = false + /// Contender count when the typed name is already in an ACTIVE masternode + /// vote started by someone else. Like a LOCK, a mid-vote label still reads + /// as available (no domain document exists until the vote resolves), but + /// submitting it joins the running vote as a contender instead of starting + /// a fresh one β€” the warning callout and the Continue confirmation switch + /// their copy on this. nil when no such contest is known (fresh name, our + /// own request, or the best-effort query failed). + @Published private(set) var activeContestContenders: Int? + + /// Deadline of that active vote, when the current-contests listing + /// reports one. Lets the warning callout say WHEN the vote ends β€” + /// and switch to "has ended, finalizing" wording once the deadline + /// passes β€” instead of an unverifiable "in progress" claim. + @Published private(set) var activeContestEndsAt: Date? + + /// Sale price (duffs) when the typed name is TAKEN but its owner has + /// listed it in the Username Marketplace. Turns the dead-end + /// "Username taken" into a pointer to the listing. nil when the name + /// is not for sale or the best-effort lookup failed. + @Published private(set) var takenNameSalePriceDuffs: UInt64? + + /// The active vote's deadline has passed β€” the poll is being + /// finalized on-chain. "Join as a contender" would be a stale claim + /// at that point, so the callout switches to "ended" wording. + var activeContestHasEnded: Bool { + guard let endsAt = activeContestEndsAt else { return false } + return endsAt <= Date() + } + + /// The vote is still running but its contender-join window + /// (`contenderJoinDeadline` β€” poll end βˆ’ 45 min testnet / βˆ’ 1 week + /// mainnet) has closed: the name can no longer be requested until + /// the vote resolves. Always true once the vote itself has ended. + /// Consulted at render time AND as the last-moment gate behind the + /// contested confirmation's "Submit anyway". + var activeContestJoinClosed: Bool { + guard let endsAt = activeContestEndsAt else { return false } + return UsernameMarketplaceService.contenderJoinDeadline(voteEnd: endsAt) <= Date() + } + + /// Whether the orange contested callout applies to the current + /// answer. A plainly TAKEN name (someone owns its document) will + /// never go to a vote β€” "requires a masternode vote" next to + /// "Username taken" (or a for-sale listing) would contradict it. + /// Locked and mid-vote answers keep the callout: it carries their + /// detail. + var showContestedWarning: Bool { + guard isContestedCandidate else { return false } + if uiState.usernameBlockedRule == .invalidCritical { + return isLockedContestedName || activeContestContenders != nil + } + return true + } + /// Per-funding-source eligibility flags. `hasMinimumRequiredBalance` /// (above) is kept as the legacy OR-of-both flag for any existing /// consumer; the new picker UI reads these to decide whether to @@ -156,6 +227,10 @@ class CreateUsernameViewModel: ObservableObject { } func refreshRegistrationRecoveryState() { + // Screen (re)appear β€” drop the cached DPNS answer so a stale + // verdict from an earlier visit (name taken meanwhile, a vote + // started or resolved) can't carry over into this one. + availabilityCheckLabel = nil validateUsername(username: username) } @@ -353,11 +428,24 @@ class CreateUsernameViewModel: ObservableObject { hasPendingRegistrationRecovery = DWIdentityRegistrationCoordinator.shared.hasPendingRegistrationRecovery() - // Kill any in-flight availability check β€” its result belongs to - // an input the user has already changed (including changed-to-empty). - availabilityCheckTask?.cancel() - - isLockedContestedName = false + // Balance/readiness publishes re-run this method constantly while + // syncing, but they only move the cost rules β€” the DPNS answer for + // an unchanged label stays good for the whole screen visit. Only a + // label change invalidates the check state; same-label runs reuse + // it below. + let labelChanged = username != availabilityCheckLabel + if labelChanged { + // Kill any in-flight availability check β€” its result belongs to + // an input the user has already changed (including changed-to-empty). + availabilityCheckTask?.cancel() + contestBoundaryRevalidationTask?.cancel() + contestBoundaryRevalidationTask = nil + availabilityCheckLabel = nil + isLockedContestedName = false + activeContestContenders = nil + activeContestEndsAt = nil + takenNameSalePriceDuffs = nil + } guard !username.isEmpty else { uiState = CreateUsernameUIState() @@ -425,16 +513,27 @@ class CreateUsernameViewModel: ObservableObject { + "recovery=\(recoveryFunded), voucher=\(voucherFunded)") } + // Same label as the existing check state β†’ carry its rule forward + // (a settled verdict stays settled; an in-flight `.loading` keeps + // its task) instead of resetting to `.loading` and re-querying. + // `.error` is deliberately NOT reused β€” it retries on the next + // revalidation, as before. `.hidden` means the check never ran for + // this label (cost rule was failing), so it must run now. + let priorRule = uiState.usernameBlockedRule + let reusePriorCheck = canContinue && !labelChanged + && priorRule != .error && priorRule != .hidden + uiState = CreateUsernameUIState( lengthRule: lengthValid ? .valid : .invalid, allowedCharactersRule: hasIllegalCharacters || startsOrEndsWithHyphen ? .invalid : .valid, costRule: voucherFunded || recoveryFunded ? .hidden : (hasEnoughBalance ? .valid : .invalid), - usernameBlockedRule: canContinue ? .loading : .hidden, + usernameBlockedRule: canContinue ? (reusePriorCheck ? priorRule : .loading) : .hidden, requiredDash: requiredCost, - canContinue: false + canContinue: reusePriorCheck && priorRule == .valid ) - if canContinue { + if canContinue && !reusePriorCheck { + availabilityCheckLabel = username availabilityCheckTask = Task { await checkIfBlocked(username: username) } @@ -442,13 +541,17 @@ class CreateUsernameViewModel: ObservableObject { } /// Debounced real DPNS availability check (same coordinator call the - /// legacy `DWCheckExistenceUsernameValidationRule` path uses). Maps: - /// available β†’ `.valid`; taken β†’ `.invalidCritical` β€” including names - /// in an ACTIVE contest, because `registerDpnsName` creates the domain - /// document immediately and voting only decides who keeps it (matches - /// the legacy rule's behavior); our OWN pending contested submission β†’ + /// legacy `DWCheckExistenceUsernameValidationRule` path uses), plus a + /// vote-state precheck for contested candidates. Maps: available with + /// no known contest β†’ `.valid`; available but mid-vote by others β†’ + /// `.valid` while the join window is open (submitting joins the vote + /// as a contender), `.invalidCritical` once it closed; available but + /// locked by a past vote β†’ `.invalidCritical`; taken β†’ + /// `.invalidCritical`; our OWN pending contested submission β†’ /// `.warning` ("in voting"); RPC failure β†’ `.error` (re-runs on the - /// next keystroke or balance publish). `canContinue` is true only for + /// next keystroke or balance publish). A taken name additionally gets + /// a best-effort marketplace lookup β€” `takenNameSalePriceDuffs` when + /// its owner listed it for sale. `canContinue` is true only for /// `.valid` β€” the submit-time `registerDpnsName` failure remains the /// second line of defense. private func checkIfBlocked(username: String) async { @@ -459,31 +562,88 @@ class CreateUsernameViewModel: ObservableObject { let result: UsernameValidationRuleResult var locked = false + var activeContenders: Int? + var activeContestEnd: Date? + var salePriceDuffs: UInt64? do { // Two independent questions about the same label, so ask both at // once. Chaining them put a second DAPI round trip behind the // first, and "Validating username…" sat there for the sum of the - // two β€” painful whenever Platform is slow. Scoped to contested - // candidates: only they can reach a locked vote poll. + // two β€” painful whenever Platform is slow. The vote-state + // precheck is scoped to contested candidates: only they can sit + // in a vote poll (resolved to a LOCK, or still being voted on). async let availability = DWIdentityRegistrationCoordinator.shared .dpnsCheckAvailability(username) - async let lockedCheck: Bool = isContestedCandidate - ? DWIdentityRegistrationCoordinator.shared.isContestedNameLocked(username) - : false + async let precheck: UsernameMarketplaceService.ContestPrecheck = isContestedCandidate + ? marketplaceService.contestPrecheck(label: username) + : .fresh let available = try await availability if available { // "Available" only means no identity owns the domain document. - // A contested label whose vote ended in a LOCK is exactly that - // β€” and unregisterable: the transition is refused at broadcast. - locked = await lockedCheck - result = locked ? .invalidCritical : .valid + // A contested label can still be spoken for: a vote that ended + // in a LOCK refuses every new submission at broadcast, and a + // still-running vote means a request joins it as a contender + // rather than claiming a free name. + switch await precheck { + case .locked: + locked = true + result = .invalidCritical + case let .activeContest(contenders, endsAt): + DWLogger.log( + "CreateUsername: '\(username)' is in an active vote " + + "(\(contenders) contenders, ends \(endsAt.map { "\($0)" } ?? "unknown"))") + // Our own running vote shows as "in voting", not as a + // joinable contest. The submission bookmark answers first; + // the SDK's local contested-names cache backs it up when + // the bookmark is gone (same two sources as the + // marketplace row's `hasRequestedContest`). + var isOwnRequest = false + if let pending = DWContestedNameStatusService.shared.pendingLabel, + DWContestedNameStatusService.labelsMatch(pending, username) { + isOwnRequest = true + } else { + isOwnRequest = await marketplaceService.myContestedNames() + .contains { DWContestedNameStatusService.labelsMatch($0, username) } + } + if isOwnRequest { + result = .warning + } else { + activeContenders = contenders + activeContestEnd = endsAt + // Contenders may only join for the first part of the + // poll (`contenderJoinDeadline`); past that a + // submission can only fail at broadcast, so Continue + // is blocked instead of promising a join the network + // will refuse. An unknown deadline stays submittable β€” + // the submit-time transition remains the authority. + if let endsAt, + UsernameMarketplaceService.contenderJoinDeadline(voteEnd: endsAt) <= Date() { + result = .invalidCritical + } else { + result = .valid + } + } + case .fresh, .unknown: + // .unknown = the precheck query failed. It must not block + // a name that is very likely fine β€” the submit-time + // transition stays the authority (mirrors the old + // locked-check's failed-query behavior). + result = .valid + } } else if let pending = DWContestedNameStatusService.shared.pendingLabel, pending.caseInsensitiveCompare(username) == .orderedSame { // Our own contested submission β€” reads as taken on-chain // while masternode voting is still deciding the owner. result = .warning } else { + // Taken β€” but the owner may have listed it in the Username + // Marketplace. Best-effort: a failed lookup just leaves the + // plain "taken" wording. + if let sale = try? await marketplaceService.nameState(username), + sale.isForSale { + salePriceDuffs = sale.priceDuffs + } result = .invalidCritical } } catch { @@ -498,8 +658,34 @@ class CreateUsernameViewModel: ObservableObject { else { return } isLockedContestedName = locked + activeContestContenders = activeContenders + activeContestEndsAt = activeContestEnd + takenNameSalePriceDuffs = salePriceDuffs uiState.usernameBlockedRule = result uiState.canContinue = (result == .valid) + armContestBoundaryRevalidation() + } + + /// Schedule the one-shot re-check at the active contest's next + /// boundary: the join-window close while joining is still open, + /// otherwise the vote end. No-op when the current answer has no + /// active contest or its deadline is unknown. + private func armContestBoundaryRevalidation() { + contestBoundaryRevalidationTask?.cancel() + contestBoundaryRevalidationTask = nil + guard activeContestContenders != nil, let endsAt = activeContestEndsAt else { return } + let joinDeadline = UsernameMarketplaceService.contenderJoinDeadline(voteEnd: endsAt) + let boundary = joinDeadline > Date() ? joinDeadline : endsAt + let delay = boundary.timeIntervalSinceNow + guard delay > 0 else { return } + contestBoundaryRevalidationTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64((delay + 1) * 1_000_000_000)) + guard !Task.isCancelled, let self else { return } + // Drop the per-label cache first β€” the boundary is exactly + // what invalidates it. + self.availabilityCheckLabel = nil + self.validateUsername(username: self.username) + } } private func observeBalance() { diff --git a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift index ce889730c..99cdabae5 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController+Shortcuts.swift @@ -214,7 +214,10 @@ extension HomeViewController: DWLocalCurrencyViewControllerDelegate { }, onProceed: { [weak self] in readinessNavigationController?.dismiss(animated: true) { - self?.pushCreateUsernameForm() + // Coming from the readiness interstitial: the shielded + // question was answered there (checklist or the explicit + // transparent escape), so the form skips the teaser. + self?.pushCreateUsernameForm(suppressShieldedHint: true) } }, onClose: { @@ -234,8 +237,9 @@ extension HomeViewController: DWLocalCurrencyViewControllerDelegate { present(modalNavigationController, animated: true) } - private func pushCreateUsernameForm(invitationURL: URL? = nil, definedUsername: String? = nil) { + private func pushCreateUsernameForm(invitationURL: URL? = nil, definedUsername: String? = nil, suppressShieldedHint: Bool = false) { let controller = CreateUsernameViewController(dashPayModel: model.dashPayModel, invitationURL: invitationURL, definedUsername: definedUsername) + controller.suppressShieldedHint = suppressShieldedHint controller.hidesBottomBarWhenPushed = true controller.completionHandler = { result in if (result) { diff --git a/DashWallet/Sources/UI/Menu/Main/MainMenuViewController.swift b/DashWallet/Sources/UI/Menu/Main/MainMenuViewController.swift index 2492ff6fc..8385f6cdb 100644 --- a/DashWallet/Sources/UI/Menu/Main/MainMenuViewController.swift +++ b/DashWallet/Sources/UI/Menu/Main/MainMenuViewController.swift @@ -545,9 +545,13 @@ struct MainMenuScreen: View { onProceed: { readinessNavigationController?.dismiss(animated: true) { guard let menuNavigationController else { return } + // Coming from the readiness interstitial: the shielded + // question was answered there (checklist or the explicit + // transparent escape), so the form skips the teaser. Self.pushCreateUsernameForm( on: menuNavigationController, - dashPayModel: dashPayModel) + dashPayModel: dashPayModel, + suppressShieldedHint: true) } }, onClose: { @@ -576,13 +580,15 @@ struct MainMenuScreen: View { private static func pushCreateUsernameForm( on navigationController: UINavigationController, - dashPayModel: DWDashPayProtocol + dashPayModel: DWDashPayProtocol, + suppressShieldedHint: Bool = false ) { let controller = CreateUsernameViewController( dashPayModel: dashPayModel, invitationURL: nil, definedUsername: nil ) + controller.suppressShieldedHint = suppressShieldedHint controller.hidesBottomBarWhenPushed = true controller.completionHandler = { [weak navigationController] result in let message = result diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index ffd65a693..cd9777a68 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -4282,6 +4282,33 @@ /* Usernames */ "This name requires voting. Your Dash will be locked until voting completes." = "This name requires voting. Your Dash will be locked until voting completes."; +/* Usernames */ +"Vote in progress" = "Vote in progress"; + +/* Usernames */ +"Vote ended" = "Vote ended"; + +/* Usernames */ +"A masternode vote for this name is already in progress. Submitting a request joins the vote as a contender." = "A masternode vote for this name is already in progress. Submitting a request joins the vote as a contender."; + +/* Usernames */ +"A masternode vote for this name is already in progress β€” voting ends around %@. Submitting a request joins the vote as a contender." = "A masternode vote for this name is already in progress β€” voting ends around %@. Submitting a request joins the vote as a contender."; + +/* Usernames */ +"A masternode vote for this name is in progress β€” voting ends around %@. New contenders can no longer join this vote." = "A masternode vote for this name is in progress β€” voting ends around %@. New contenders can no longer join this vote."; + +/* Usernames */ +"Username is in a network vote β€” new contenders can no longer join" = "Username is in a network vote β€” new contenders can no longer join"; + +/* Usernames */ +"The owner of this username has listed it in the Username Marketplace for %@ Dash. You can buy it there instead." = "The owner of this username has listed it in the Username Marketplace for %@ Dash. You can buy it there instead."; + +/* Usernames */ +"The masternode vote for this name has ended and the result is being finalized. Check back soon." = "The masternode vote for this name has ended and the result is being finalized. Check back soon."; + +/* Usernames */ +"A vote for this name is already in progress β€” your request will join it as a contender. Your Dash will be locked until voting completes." = "A vote for this name is already in progress β€” your request will join it as a contender. Your Dash will be locked until voting completes."; + /* No comment provided by engineer. */ "This permanently removes the wallet, private keys, and recovery phrase from this device. This cannot be undone." = "This permanently removes the wallet, private keys, and recovery phrase from this device. This cannot be undone.";