Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -307,16 +313,28 @@ 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
// both resolve to locked-for-registration.
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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -50,7 +56,8 @@ class CreateUsernameViewController: UIViewController {

let content = CreateUsernameView(
invitationURI: invitationURI,
definedUsername: definedUsername
definedUsername: definedUsername,
suppressShieldedHint: suppressShieldedHint
) {
let navigationController = self.navigationController
#if DASHPAY
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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"))
Comment thread
QuantumExplorer marked this conversation as resolved.
}
.alert(
NSLocalizedString("Username registered", comment: "Usernames"),
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading